feat: Add identification report and clear denied records functionality
This commit introduces new API endpoints for generating identification reports and clearing denied records from the database. The frontend has been updated to include a report button that fetches user identification statistics, allowing admins to view how many faces each user identified over a specified date range. Additionally, a clear denied records button has been added to permanently remove all denied identifications. The necessary data models and response structures have been implemented to support these features. Documentation has been updated to reflect these changes.
This commit is contained in:
@@ -74,6 +74,50 @@ def get_current_user(
|
||||
)
|
||||
|
||||
|
||||
def get_current_user_with_id(
|
||||
current_user: Annotated[dict, Depends(get_current_user)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Get current user with ID from main database.
|
||||
|
||||
Looks up the user in the main database and returns username and user_id.
|
||||
If user doesn't exist, creates them (for bootstrap scenarios).
|
||||
"""
|
||||
username = current_user["username"]
|
||||
|
||||
# Check if user exists in main database
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
|
||||
# If user doesn't exist, create them (for bootstrap scenarios)
|
||||
if not user:
|
||||
from src.web.utils.password import hash_password
|
||||
|
||||
# Generate unique email to avoid conflicts
|
||||
base_email = f"{username}@example.com"
|
||||
email = base_email
|
||||
counter = 1
|
||||
# Ensure email is unique
|
||||
while db.query(User).filter(User.email == email).first():
|
||||
email = f"{username}+{counter}@example.com"
|
||||
counter += 1
|
||||
|
||||
# Create user (they should change password)
|
||||
default_password_hash = hash_password("changeme")
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash=default_password_hash,
|
||||
email=email,
|
||||
full_name=username,
|
||||
is_active=True,
|
||||
is_admin=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {"username": username, "user_id": user.id}
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(credentials: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse:
|
||||
"""Authenticate user and return tokens.
|
||||
|
||||
@@ -8,8 +8,10 @@ from rq import Queue
|
||||
from redis import Redis
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Annotated
|
||||
|
||||
from src.web.db.session import get_db
|
||||
from src.web.api.auth import get_current_user_with_id
|
||||
from src.web.schemas.faces import (
|
||||
ProcessFacesRequest,
|
||||
ProcessFacesResponse,
|
||||
@@ -260,12 +262,16 @@ def get_batch_similarities(
|
||||
def identify_face(
|
||||
face_id: int,
|
||||
request: IdentifyFaceRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> IdentifyFaceResponse:
|
||||
"""Assign a face (and optional batch) to a person, creating if needed.
|
||||
|
||||
Also inserts into person_encodings for each identified face as desktop does.
|
||||
Tracks which user identified the face.
|
||||
"""
|
||||
user_id = current_user["user_id"]
|
||||
|
||||
# Validate target face
|
||||
face = db.query(Face).filter(Face.id == face_id).first()
|
||||
if not face:
|
||||
@@ -307,6 +313,7 @@ def identify_face(
|
||||
if not f:
|
||||
continue
|
||||
f.person_id = person.id
|
||||
f.identified_by_user_id = user_id
|
||||
db.add(f)
|
||||
# Insert person_encoding
|
||||
pe = PersonEncoding(
|
||||
|
||||
@@ -5,18 +5,53 @@ from __future__ import annotations
|
||||
from datetime import date, datetime
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import text, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.db.session import get_auth_db, get_db
|
||||
from src.web.db.models import Face, Person, PersonEncoding
|
||||
from src.web.db.models import Face, Person, PersonEncoding, User
|
||||
from src.web.api.users import get_current_admin_user
|
||||
from src.web.utils.password import hash_password
|
||||
|
||||
router = APIRouter(prefix="/pending-identifications", tags=["pending-identifications"])
|
||||
|
||||
|
||||
def get_or_create_frontend_user(db: Session) -> User:
|
||||
"""Get or create the special 'FrontEndUser' system user.
|
||||
|
||||
This user represents identifications made through the frontend approval UI,
|
||||
distinguishing them from direct user identifications.
|
||||
"""
|
||||
FRONTEND_USERNAME = "FrontEndUser"
|
||||
|
||||
# Try to get existing user
|
||||
user = db.query(User).filter(User.username == FRONTEND_USERNAME).first()
|
||||
|
||||
if user:
|
||||
return user
|
||||
|
||||
# Create the system user if it doesn't exist
|
||||
# Use a non-loginable password hash (random, won't be used for login)
|
||||
default_password_hash = hash_password("system_user_not_for_login")
|
||||
|
||||
user = User(
|
||||
username=FRONTEND_USERNAME,
|
||||
password_hash=default_password_hash,
|
||||
email="frontend@punimtag.system",
|
||||
full_name="Frontend System User",
|
||||
is_active=False, # Not an active user, just a system marker
|
||||
is_admin=False,
|
||||
password_change_required=False,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
class PendingIdentificationResponse(BaseModel):
|
||||
"""Pending identification DTO returned from API."""
|
||||
|
||||
@@ -74,6 +109,39 @@ class ApproveDenyResponse(BaseModel):
|
||||
errors: list[str]
|
||||
|
||||
|
||||
class UserIdentificationStats(BaseModel):
|
||||
"""Statistics for a single user's identifications."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
user_id: int
|
||||
username: str
|
||||
full_name: str
|
||||
email: str
|
||||
face_count: int
|
||||
first_identification_date: Optional[datetime] = None
|
||||
last_identification_date: Optional[datetime] = None
|
||||
|
||||
|
||||
class IdentificationReportResponse(BaseModel):
|
||||
"""Response containing identification statistics grouped by user."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
items: list[UserIdentificationStats]
|
||||
total_faces: int
|
||||
total_users: int
|
||||
|
||||
|
||||
class ClearDatabaseResponse(BaseModel):
|
||||
"""Response from clearing denied records."""
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
deleted_records: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
@router.get("", response_model=PendingIdentificationsListResponse)
|
||||
def list_pending_identifications(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
@@ -277,7 +345,10 @@ def approve_deny_pending_identifications(
|
||||
created_person = True
|
||||
|
||||
# Link face to person
|
||||
# Use FrontEndUser to indicate this was approved through the frontend UI
|
||||
frontend_user = get_or_create_frontend_user(main_db)
|
||||
face.person_id = person.id
|
||||
face.identified_by_user_id = frontend_user.id
|
||||
main_db.add(face)
|
||||
|
||||
# Insert person_encoding
|
||||
@@ -327,3 +398,144 @@ def approve_deny_pending_identifications(
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@router.get("/report", response_model=IdentificationReportResponse)
|
||||
def get_identification_report(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
date_from: Optional[str] = Query(None, description="Filter by identification date (from) - YYYY-MM-DD"),
|
||||
date_to: Optional[str] = Query(None, description="Filter by identification date (to) - YYYY-MM-DD"),
|
||||
main_db: Session = Depends(get_db),
|
||||
) -> IdentificationReportResponse:
|
||||
"""Get identification statistics grouped by user.
|
||||
|
||||
Shows how many faces each user identified and when.
|
||||
Can be filtered by date range using PersonEncoding.created_date.
|
||||
"""
|
||||
# Query faces that have been identified (have person_id and identified_by_user_id)
|
||||
# Join with PersonEncoding to get created_date (when face was identified)
|
||||
# Join with User to get user information
|
||||
# Use distinct count to avoid counting the same face multiple times
|
||||
# (in case person encodings were updated, creating multiple PersonEncoding records)
|
||||
query = (
|
||||
main_db.query(
|
||||
User.id.label('user_id'),
|
||||
User.username,
|
||||
User.full_name,
|
||||
User.email,
|
||||
func.count(func.distinct(Face.id)).label('face_count'),
|
||||
func.min(PersonEncoding.created_date).label('first_date'),
|
||||
func.max(PersonEncoding.created_date).label('last_date')
|
||||
)
|
||||
.join(Face, User.id == Face.identified_by_user_id)
|
||||
.join(PersonEncoding, Face.id == PersonEncoding.face_id)
|
||||
.filter(Face.person_id.isnot(None))
|
||||
.filter(Face.identified_by_user_id.isnot(None))
|
||||
.group_by(User.id, User.username, User.full_name, User.email)
|
||||
)
|
||||
|
||||
# Apply date filtering if provided (filter before grouping)
|
||||
if date_from:
|
||||
try:
|
||||
date_from_obj = datetime.strptime(date_from, "%Y-%m-%d").date()
|
||||
query = query.filter(func.date(PersonEncoding.created_date) >= date_from_obj)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid date_from format. Use YYYY-MM-DD"
|
||||
)
|
||||
|
||||
if date_to:
|
||||
try:
|
||||
date_to_obj = datetime.strptime(date_to, "%Y-%m-%d").date()
|
||||
query = query.filter(func.date(PersonEncoding.created_date) <= date_to_obj)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid date_to format. Use YYYY-MM-DD"
|
||||
)
|
||||
|
||||
# Execute query and get results
|
||||
results = query.order_by(func.count(Face.id).desc(), User.username.asc()).all()
|
||||
|
||||
# Convert to response model
|
||||
items = []
|
||||
total_faces = 0
|
||||
|
||||
for row in results:
|
||||
total_faces += row.face_count
|
||||
items.append(
|
||||
UserIdentificationStats(
|
||||
user_id=row.user_id,
|
||||
username=row.username,
|
||||
full_name=row.full_name or row.username,
|
||||
email=row.email,
|
||||
face_count=row.face_count,
|
||||
first_identification_date=row.first_date,
|
||||
last_identification_date=row.last_date,
|
||||
)
|
||||
)
|
||||
|
||||
return IdentificationReportResponse(
|
||||
items=items,
|
||||
total_faces=total_faces,
|
||||
total_users=len(items)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/clear-denied", response_model=ClearDatabaseResponse)
|
||||
def clear_denied_identifications(
|
||||
current_admin: Annotated[dict, Depends(get_current_admin_user)],
|
||||
auth_db: Session = Depends(get_auth_db),
|
||||
) -> ClearDatabaseResponse:
|
||||
"""Delete all denied pending identifications from the database.
|
||||
|
||||
This permanently removes all records with status='denied' from the
|
||||
pending_identifications table in the auth database.
|
||||
"""
|
||||
deleted_records = 0
|
||||
errors = []
|
||||
|
||||
try:
|
||||
# First check if there are any denied records
|
||||
check_result = auth_db.execute(text("""
|
||||
SELECT COUNT(*) as count FROM pending_identifications
|
||||
WHERE status = 'denied'
|
||||
"""))
|
||||
denied_count = check_result.fetchone().count if check_result else 0
|
||||
|
||||
if denied_count == 0:
|
||||
# No denied records to delete
|
||||
return ClearDatabaseResponse(
|
||||
deleted_records=0,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
# Delete all denied records
|
||||
result = auth_db.execute(text("""
|
||||
DELETE FROM pending_identifications
|
||||
WHERE status = 'denied'
|
||||
"""))
|
||||
|
||||
deleted_records = result.rowcount if hasattr(result, 'rowcount') else 0
|
||||
auth_db.commit()
|
||||
|
||||
if deleted_records == 0 and denied_count > 0:
|
||||
errors.append("No records were deleted despite finding denied records")
|
||||
|
||||
except Exception as e:
|
||||
auth_db.rollback()
|
||||
error_msg = str(e)
|
||||
errors.append(f"Error deleting denied records: {error_msg}")
|
||||
|
||||
# Check if it's a permission error
|
||||
if "permission denied" in error_msg.lower() or "insufficient privilege" in error_msg.lower():
|
||||
errors.append(
|
||||
"Database permission error. Please run this command manually:\n"
|
||||
"sudo -u postgres psql -d punimtag_auth -c \"GRANT DELETE ON TABLE pending_identifications TO punimtag;\""
|
||||
)
|
||||
|
||||
return ClearDatabaseResponse(
|
||||
deleted_records=deleted_records,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.web.db.session import get_db
|
||||
from src.web.db.models import Person, Face
|
||||
from src.web.api.auth import get_current_user_with_id
|
||||
from src.web.schemas.people import (
|
||||
PeopleListResponse,
|
||||
PersonCreateRequest,
|
||||
@@ -185,6 +188,7 @@ def get_person_faces(person_id: int, db: Session = Depends(get_db)) -> PersonFac
|
||||
def accept_matches(
|
||||
person_id: int,
|
||||
request: AcceptMatchesRequest,
|
||||
current_user: Annotated[dict, Depends(get_current_user_with_id)],
|
||||
db: Session = Depends(get_db),
|
||||
) -> IdentifyFaceResponse:
|
||||
"""Accept auto-match matches for a person.
|
||||
@@ -193,9 +197,13 @@ def accept_matches(
|
||||
1. Identifies selected faces with this person
|
||||
2. Inserts person_encodings for each identified face
|
||||
3. Updates person encodings (removes old, adds current)
|
||||
Tracks which user identified the faces.
|
||||
"""
|
||||
from src.web.api.auth import get_current_user_with_id
|
||||
|
||||
user_id = current_user["user_id"]
|
||||
identified_count, updated_count = accept_auto_match_matches(
|
||||
db, person_id, request.face_ids
|
||||
db, person_id, request.face_ids, user_id=user_id
|
||||
)
|
||||
|
||||
return IdentifyFaceResponse(
|
||||
|
||||
@@ -221,6 +221,47 @@ def ensure_user_email_unique_constraint(inspector) -> None:
|
||||
print("ℹ️ SQLite: Unique constraint on email will be enforced by model definition for new tables")
|
||||
|
||||
|
||||
def ensure_face_identified_by_user_id_column(inspector) -> None:
|
||||
"""Ensure faces table contains identified_by_user_id column."""
|
||||
if "faces" not in inspector.get_table_names():
|
||||
return
|
||||
|
||||
columns = {column["name"] for column in inspector.get_columns("faces")}
|
||||
if "identified_by_user_id" in columns:
|
||||
print("ℹ️ identified_by_user_id column already exists in faces table")
|
||||
return
|
||||
|
||||
print("🔄 Adding identified_by_user_id column to faces table...")
|
||||
dialect = engine.dialect.name
|
||||
|
||||
with engine.connect() as connection:
|
||||
with connection.begin():
|
||||
if dialect == "postgresql":
|
||||
connection.execute(
|
||||
text("ALTER TABLE faces ADD COLUMN IF NOT EXISTS identified_by_user_id INTEGER REFERENCES users(id)")
|
||||
)
|
||||
# Add index
|
||||
try:
|
||||
connection.execute(
|
||||
text("CREATE INDEX IF NOT EXISTS idx_faces_identified_by ON faces(identified_by_user_id)")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
else:
|
||||
# SQLite
|
||||
connection.execute(
|
||||
text("ALTER TABLE faces ADD COLUMN identified_by_user_id INTEGER REFERENCES users(id)")
|
||||
)
|
||||
# SQLite doesn't support IF NOT EXISTS for indexes, so we'll try to create it
|
||||
try:
|
||||
connection.execute(
|
||||
text("CREATE INDEX idx_faces_identified_by ON faces(identified_by_user_id)")
|
||||
)
|
||||
except Exception:
|
||||
pass # Index might already exist
|
||||
print("✅ Added identified_by_user_id column to faces table")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Lifespan context manager for startup and shutdown events."""
|
||||
@@ -255,6 +296,7 @@ async def lifespan(app: FastAPI):
|
||||
ensure_user_password_hash_column(inspector)
|
||||
ensure_user_password_change_required_column(inspector)
|
||||
ensure_user_email_unique_constraint(inspector)
|
||||
ensure_face_identified_by_user_id_column(inspector)
|
||||
except Exception as exc:
|
||||
print(f"❌ Database initialization failed: {exc}")
|
||||
raise
|
||||
|
||||
@@ -102,6 +102,7 @@ class Face(Base):
|
||||
pitch_angle = Column(Numeric, nullable=True)
|
||||
roll_angle = Column(Numeric, nullable=True)
|
||||
landmarks = Column(Text, nullable=True) # JSON string of facial landmarks
|
||||
identified_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True, index=True)
|
||||
|
||||
photo = relationship("Photo", back_populates="faces")
|
||||
person = relationship("Person", back_populates="faces")
|
||||
@@ -114,6 +115,7 @@ class Face(Base):
|
||||
Index("idx_faces_photo_id", "photo_id"),
|
||||
Index("idx_faces_quality", "quality_score"),
|
||||
Index("idx_faces_pose_mode", "pose_mode"),
|
||||
Index("idx_faces_identified_by", "identified_by_user_id"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1988,6 +1988,7 @@ def accept_auto_match_matches(
|
||||
db: Session,
|
||||
person_id: int,
|
||||
face_ids: List[int],
|
||||
user_id: int | None = None,
|
||||
) -> Tuple[int, int]:
|
||||
"""Accept auto-match matches for a person, matching desktop logic exactly.
|
||||
|
||||
@@ -1996,6 +1997,12 @@ def accept_auto_match_matches(
|
||||
2. Insert person_encodings for each identified face
|
||||
3. Update person encodings (remove old, add current)
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
person_id: Person ID to identify faces with
|
||||
face_ids: List of face IDs to identify
|
||||
user_id: User ID who is performing the identification (optional)
|
||||
|
||||
Returns:
|
||||
(identified_count, updated_count) tuple
|
||||
"""
|
||||
@@ -2017,6 +2024,8 @@ def accept_auto_match_matches(
|
||||
for face in faces:
|
||||
# Set person_id on face
|
||||
face.person_id = person_id
|
||||
if user_id is not None:
|
||||
face.identified_by_user_id = user_id
|
||||
db.add(face)
|
||||
|
||||
# Insert person_encoding (matching desktop)
|
||||
|
||||
Reference in New Issue
Block a user