From 29ed1078c2adb5eb0e06e5b17093243b7b284b27 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sat, 21 Mar 2026 19:37:26 +0100 Subject: [PATCH 01/47] feat: return all detected faces with embeddings --- app/service/face_embedding.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/app/service/face_embedding.py b/app/service/face_embedding.py index 076d29e..d644906 100644 --- a/app/service/face_embedding.py +++ b/app/service/face_embedding.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from typing import List, Literal, Optional, Sequence, Tuple, TypedDict import cv2 # type: ignore @@ -27,6 +28,12 @@ class FaceStub: embedding: Optional[np.ndarray] = None +@dataclass(frozen=True) +class DetectedFace: + embedding: list[float] + bbox: Tuple[float, float, float, float] + + class FaceEmbedding: def __init__( self, @@ -106,6 +113,12 @@ def embed(self, image: np.ndarray, bboxes: Sequence[BBox]) -> list[float]: return embedding.tolist() +class DetectedFace: + def __init__(self, embedding: list[float], bbox: tuple[float, float, float, float]): + self.embedding = embedding + self.bbox = bbox + + class FaceEmbeddingService: def __init__(self, face_embedding: FaceEmbedding | None = None) -> None: self.face_embedding = face_embedding or FaceEmbedding() @@ -184,6 +197,26 @@ async def compute_event_embedding( return results + async def detect_faces( + self, + payload: FaceImagePayload, + ) -> list[DetectedFace]: + image = self._decode_image(payload) + image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + + faces: list[FaceStub] = await asyncio.to_thread( # type: ignore + self.face_embedding.model.get, image_rgb # type: ignore + ) + + detected: list[DetectedFace] = [] + for face in faces: + if face.embedding is None: + continue + embedding = face.embedding.astype(float).flatten().tolist() + detected.append(DetectedFace(embedding=embedding, bbox=face.bbox)) + + return detected + def _decode_image(self, payload: FaceImagePayload) -> np.ndarray: buffer = np.frombuffer(payload["bytes"], dtype=np.uint8) From 3ee195888fa550fb13c2e07b4c0b112d954b3d37 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sat, 21 Mar 2026 19:38:06 +0100 Subject: [PATCH 02/47] feat: add batch face embedding service --- app/service/batch_face_embedding.py | 306 ++++++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 app/service/batch_face_embedding.py diff --git a/app/service/batch_face_embedding.py b/app/service/batch_face_embedding.py new file mode 100644 index 0000000..5a13d88 --- /dev/null +++ b/app/service/batch_face_embedding.py @@ -0,0 +1,306 @@ +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +import json +import mimetypes +from pathlib import Path +import uuid +from typing import Literal +from urllib.parse import urlparse, parse_qs + +from sqlalchemy.exc import IntegrityError + +from app.core.exceptions import AppException +from app.core.logger import logger +from app.infra.google_drive import GoogleDriveClient, GoogleDriveFileDownload +from app.infra.minio import Bucket, IMAGES_BUCKET_NAME +from app.service.face_embedding import DetectedFace, FaceEmbeddingService, FaceImagePayload +from app.service.staff_drive import StaffDriveService +from db.generated import models +from db.generated import photo_faces as photo_face_queries + + +SourceType = Literal["drive", "minio", "local"] + + +@dataclass(frozen=True) +class BatchImageInput: + photo_id: uuid.UUID + source_type: SourceType + source: str + + +@dataclass(frozen=True) +class BatchImageResult: + photo_id: uuid.UUID + source_type: SourceType + source: str + faces_detected: int + faces_stored: int + errors: list[str] + + +@dataclass(frozen=True) +class BatchFaceEmbeddingSummary: + total_images: int + total_faces_detected: int + total_faces_stored: int + failures: int + results: list[BatchImageResult] + + +class BatchFaceEmbeddingService: + def __init__( + self, + face_embedding_service: FaceEmbeddingService, + staff_drive_service: StaffDriveService, + photo_face_querier: photo_face_queries.AsyncQuerier, + ) -> None: + self.face_embedding_service = face_embedding_service + self.staff_drive_service = staff_drive_service + self.photo_face_querier = photo_face_querier + self.default_bucket = Bucket(IMAGES_BUCKET_NAME, "") + + async def process_batch( + self, + *, + items: Sequence[BatchImageInput], + staff_user_id: uuid.UUID | None, + ) -> BatchFaceEmbeddingSummary: + if not items: + raise AppException.bad_request("At least one image is required") + + access_token: str | None = None + if any(item.source_type == "drive" for item in items): + if staff_user_id is None: + raise AppException.bad_request("staff_user_id is required for drive sources") + access_token = await self.staff_drive_service.get_access_token_for_staff_user( + staff_user_id + ) + + results: list[BatchImageResult] = [] + total_faces_detected = 0 + total_faces_stored = 0 + + for item in items: + errors: list[str] = [] + faces_detected = 0 + faces_stored = 0 + + try: + payload = await self._load_payload(item, access_token) + except Exception as exc: + logger.warning( + "Failed to load image for photo %s (%s): %s", + item.photo_id, + item.source, + exc, + ) + errors.append(str(exc)) + results.append( + BatchImageResult( + photo_id=item.photo_id, + source_type=item.source_type, + source=item.source, + faces_detected=0, + faces_stored=0, + errors=errors, + ) + ) + continue + + try: + faces = await self.face_embedding_service.detect_faces(payload) + faces_detected = len(faces) + total_faces_detected += faces_detected + except Exception as exc: + logger.warning( + "Face detection failed for photo %s: %s", + item.photo_id, + exc, + ) + errors.append(str(exc)) + results.append( + BatchImageResult( + photo_id=item.photo_id, + source_type=item.source_type, + source=item.source, + faces_detected=0, + faces_stored=0, + errors=errors, + ) + ) + continue + + if not faces: + errors.append("No faces detected") + + for face_index, face in enumerate(faces): + try: + stored = await self._store_face( + photo_id=item.photo_id, + face_index=face_index, + face=face, + ) + if stored is None: + raise AppException.internal_error("Failed to store face embedding") + faces_stored += 1 + total_faces_stored += 1 + except IntegrityError as exc: + logger.warning( + "Failed to store face %s for photo %s: %s", + face_index, + item.photo_id, + exc, + ) + errors.append(f"face {face_index}: {exc}") + except Exception as exc: + logger.warning( + "Failed to store face %s for photo %s: %s", + face_index, + item.photo_id, + exc, + ) + errors.append(f"face {face_index}: {exc}") + + results.append( + BatchImageResult( + photo_id=item.photo_id, + source_type=item.source_type, + source=item.source, + faces_detected=faces_detected, + faces_stored=faces_stored, + errors=errors, + ) + ) + + failures = sum(1 for result in results if result.errors) + return BatchFaceEmbeddingSummary( + total_images=len(items), + total_faces_detected=total_faces_detected, + total_faces_stored=total_faces_stored, + failures=failures, + results=results, + ) + + async def _load_payload( + self, + item: BatchImageInput, + access_token: str | None, + ) -> FaceImagePayload: + if item.source_type == "drive": + if access_token is None: + raise AppException.bad_request("Missing Google Drive access token") + return await self._load_from_drive(item.source, access_token) + if item.source_type == "minio": + return await self._load_from_minio(item.source) + if item.source_type == "local": + return self._load_from_local(item.source) + raise AppException.bad_request("Unsupported source type") + + async def _load_from_drive( + self, + source: str, + access_token: str, + ) -> FaceImagePayload: + file_id = self._extract_drive_file_id(source) + downloaded = await GoogleDriveClient.download_file( + access_token=access_token, + file_id=file_id, + ) + return self._payload_from_drive(downloaded) + + async def _load_from_minio(self, source: str) -> FaceImagePayload: + bucket_name, object_name = self._parse_minio_source(source) + bucket = self.default_bucket + if bucket_name != IMAGES_BUCKET_NAME: + bucket = Bucket(bucket_name, "") + data, filename, content_type = await bucket.get(object_name) + return FaceImagePayload( + filename=filename, + content_type=content_type, + bytes=data, + ) + + def _load_from_local(self, source: str) -> FaceImagePayload: + path = self._normalize_local_path(source) + if not path.exists() or not path.is_file(): + raise AppException.not_found(f"Local file not found: {path}") + data = path.read_bytes() + content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + return FaceImagePayload( + filename=path.name, + content_type=content_type, + bytes=data, + ) + + async def _store_face( + self, + *, + photo_id: uuid.UUID, + face_index: int, + face: DetectedFace, + ) -> models.PhotoFace | None: + bbox_payload = { + "x1": face.bbox[0], + "y1": face.bbox[1], + "x2": face.bbox[2], + "y2": face.bbox[3], + } + embedding_literal = self._vector_literal(face.embedding) + return await self.photo_face_querier.upsert_photo_face( + arg=photo_face_queries.UpsertPhotoFaceParams( + photo_id=photo_id, + face_index=face_index, + embedding=embedding_literal, + bbox=json.dumps(bbox_payload), + ) + ) + + @staticmethod + def _vector_literal(embedding: Sequence[float]) -> str: + return "[" + ", ".join(str(x) for x in embedding) + "]" + + @staticmethod + def _payload_from_drive(downloaded: GoogleDriveFileDownload) -> FaceImagePayload: + metadata = downloaded.metadata + return FaceImagePayload( + filename=metadata.name, + content_type=metadata.mime_type, + bytes=downloaded.content, + ) + + @staticmethod + def _extract_drive_file_id(source: str) -> str: + if source.startswith("http://") or source.startswith("https://"): + parsed = urlparse(source) + if "drive.google.com" not in parsed.netloc and "docs.google.com" not in parsed.netloc: + raise AppException.bad_request("Invalid Google Drive URL") + query_id = parse_qs(parsed.query).get("id") + if query_id and query_id[0]: + return query_id[0] + parts = [part for part in parsed.path.split("/") if part] + if "d" in parts: + d_index = parts.index("d") + if d_index + 1 < len(parts): + return parts[d_index + 1] + raise AppException.bad_request("Google Drive file id not found in URL") + return source + + @staticmethod + def _parse_minio_source(source: str) -> tuple[str, str]: + if source.startswith("minio://"): + raw = source[len("minio://") :] + parts = raw.split("/", 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + raise AppException.bad_request("Invalid MinIO source format") + return parts[0], parts[1] + return IMAGES_BUCKET_NAME, source + + @staticmethod + def _normalize_local_path(source: str) -> Path: + normalized = source + if source.startswith("file://"): + normalized = source[len("file://") :] + return Path(normalized) From ea807d3c25ae19e5feca7ae24156c810ca0b99ad Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sat, 21 Mar 2026 19:38:21 +0100 Subject: [PATCH 03/47] feat: add batch face embeddings endpoint --- app/router/staff/face_embeddings.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 app/router/staff/face_embeddings.py diff --git a/app/router/staff/face_embeddings.py b/app/router/staff/face_embeddings.py new file mode 100644 index 0000000..1b60335 --- /dev/null +++ b/app/router/staff/face_embeddings.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends + +from app.container import Container, get_container +from app.deps.cookie_auth import get_current_staff_user +from app.schema.request.staff.face_embeddings import BatchFaceEmbeddingsRequest +from app.schema.response.staff.face_embeddings import BatchFaceEmbeddingResponse +from db.generated.models import StaffUser + + +router = APIRouter(prefix="/batch") + + +@router.post("/face-embeddings", response_model=BatchFaceEmbeddingResponse) +async def batch_face_embeddings( + req: BatchFaceEmbeddingsRequest, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> BatchFaceEmbeddingResponse: + summary = await container.batch_face_embedding_service.process_batch( + items=req.to_inputs(), + staff_user_id=current_staff_user.id, + ) + return BatchFaceEmbeddingResponse.from_summary(summary) From 5ff8ab0a9ca959fc3a14be41f192f0ea4b5f9df3 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sat, 21 Mar 2026 19:38:39 +0100 Subject: [PATCH 04/47] chore: register batch face embeddings router --- app/router/staff/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/router/staff/__init__.py b/app/router/staff/__init__.py index e17b02c..36c4013 100644 --- a/app/router/staff/__init__.py +++ b/app/router/staff/__init__.py @@ -1,10 +1,12 @@ from fastapi import APIRouter from app.router.staff.drive import router as staff_drive_router +from app.router.staff.face_embeddings import router as staff_face_embeddings_router from app.router.staff.notifications import router as staff_notifications_router from app.router.staff.uploads import router as staff_uploads_router router = APIRouter(prefix="/stuff", tags=["stuff"]) router.include_router(staff_drive_router) +router.include_router(staff_face_embeddings_router) router.include_router(staff_notifications_router) router.include_router(staff_uploads_router) From 3000eea17d14e6fed82ce453b0e189919213770c Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sat, 21 Mar 2026 19:38:39 +0100 Subject: [PATCH 05/47] feat: wire batch face embedding service in container --- app/container.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/app/container.py b/app/container.py index fd94a4f..c8c8c1c 100644 --- a/app/container.py +++ b/app/container.py @@ -5,6 +5,7 @@ from app.infra.database import get_db from app.infra.redis import RedisClient from app.service.device import DeviceService +from app.service.batch_face_embedding import BatchFaceEmbeddingService from app.service.face_embedding import FaceEmbeddingService from app.service.session import SessionService from app.service.staged_upload_storage import StagedUploadStorageService @@ -17,6 +18,7 @@ from app.service.users import AuthService from app.service.user_notification import UserNotificationService from db.generated import devices as device_queries +from db.generated import photo_faces as photo_face_queries from db.generated import photos as photo_queries from db.generated import session as session_queries from db.generated import staff_drive_connections as staff_drive_queries @@ -54,6 +56,7 @@ def __init__( self.upload_request_querier = upload_request_queries.AsyncQuerier(conn) self.upload_request_photo_querier = upload_request_photo_queries.AsyncQuerier(conn) self.photo_querier = photo_queries.AsyncQuerier(conn) + self.photo_face_querier = photo_face_queries.AsyncQuerier(conn) self.staff_notification_querier = staff_notification_queries.AsyncQuerier(conn) self.notification_querier = notification_queries.AsyncQuerier(conn) self.audit_querier = audit_queries.AsyncQuerier(conn) @@ -102,6 +105,7 @@ def __init__( staff_notifications_service=self.staff_notifications_service, ) +<<<<<<< HEAD notification_queue = NotificationQueue(settings=NotifSetting) self.user_notifications_service = UserNotificationService( @@ -112,6 +116,12 @@ def __init__( self.audit_service = AuditService( audit_querier=self.audit_querier, user_querier=self.user_querier, +======= + self.batch_face_embedding_service = BatchFaceEmbeddingService( + face_embedding_service=self.face_embedding_service, + staff_drive_service=self.staff_drive_service, + photo_face_querier=self.photo_face_querier, +>>>>>>> 08a1d9f (feat: wire batch face embedding service in container) ) self.staff_user_service = StaffUserService() From 86226128fab89308009a737749f6393532052685 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sat, 21 Mar 2026 19:42:09 +0100 Subject: [PATCH 06/47] feat: add photo_faces upsert query --- db/queries/photo_faces.sql | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 db/queries/photo_faces.sql diff --git a/db/queries/photo_faces.sql b/db/queries/photo_faces.sql new file mode 100644 index 0000000..de3ffbb --- /dev/null +++ b/db/queries/photo_faces.sql @@ -0,0 +1,13 @@ +-- name: UpsertPhotoFace :one +INSERT INTO photo_faces ( + photo_id, + face_index, + embedding, + bbox +) VALUES ( + $1, $2, $3::vector, $4 +) +ON CONFLICT (photo_id, face_index) +DO UPDATE SET embedding = EXCLUDED.embedding, + bbox = EXCLUDED.bbox +RETURNING *; From e887441cf3150167961a01e2fc8dad4e2e4e2ab9 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sat, 21 Mar 2026 19:42:09 +0100 Subject: [PATCH 07/47] chore: add generated photo_faces querier --- db/generated/photo_faces.py | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 db/generated/photo_faces.py diff --git a/db/generated/photo_faces.py b/db/generated/photo_faces.py new file mode 100644 index 0000000..09d76f1 --- /dev/null +++ b/db/generated/photo_faces.py @@ -0,0 +1,50 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.30.0 +# source: photo_faces.sql +from typing import Any, Optional +import uuid + +import sqlalchemy +import sqlalchemy.ext.asyncio + +from db.generated import models + + +UPSERT_PHOTO_FACE = """-- name: upsert_photo_face \\:one +INSERT INTO photo_faces ( + photo_id, + face_index, + embedding, + bbox +) VALUES ( + :p1, :p2, :p3\\:\\:vector, :p4 +) +ON CONFLICT (photo_id, face_index) +DO UPDATE SET embedding = EXCLUDED.embedding, + bbox = EXCLUDED.bbox +RETURNING id, photo_id, face_index, embedding, bbox, created_at +""" + + +class AsyncQuerier: + def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): + self._conn = conn + + async def upsert_photo_face(self, *, photo_id: uuid.UUID, face_index: int, dollar_3: Any, bbox: Optional[str]) -> Optional[models.PhotoFace]: + row = (await self._conn.execute(sqlalchemy.text(UPSERT_PHOTO_FACE), { + "p1": photo_id, + "p2": face_index, + "p3": dollar_3, + "p4": bbox, + })).first() + if row is None: + return None + return models.PhotoFace( + id=row[0], + photo_id=row[1], + face_index=row[2], + embedding=row[3], + bbox=row[4], + created_at=row[5], + ) From 29a2ea61a6403acaebccaa77f2a9bb932230b2bd Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sat, 21 Mar 2026 19:42:39 +0100 Subject: [PATCH 08/47] feat: add batch face embeddings request schema --- app/schema/request/staff/face_embeddings.py | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 app/schema/request/staff/face_embeddings.py diff --git a/app/schema/request/staff/face_embeddings.py b/app/schema/request/staff/face_embeddings.py new file mode 100644 index 0000000..6d9e88b --- /dev/null +++ b/app/schema/request/staff/face_embeddings.py @@ -0,0 +1,32 @@ +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, Field + +from app.service.batch_face_embedding import BatchImageInput + + +MAX_BATCH_SIZE = 200 + + +class BatchFaceEmbeddingItemRequest(BaseModel): + photo_id: UUID + source_type: Literal["drive", "minio", "local"] + source: str = Field(min_length=1, max_length=2048) + + def to_input(self) -> BatchImageInput: + return BatchImageInput( + photo_id=self.photo_id, + source_type=self.source_type, + source=self.source, + ) + + +class BatchFaceEmbeddingsRequest(BaseModel): + items: list[BatchFaceEmbeddingItemRequest] = Field( + min_length=1, + max_length=MAX_BATCH_SIZE, + ) + + def to_inputs(self) -> list[BatchImageInput]: + return [item.to_input() for item in self.items] From 6586db4dc7f8f760153f5335f4aaa62e58a97f6f Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sat, 21 Mar 2026 19:42:39 +0100 Subject: [PATCH 09/47] feat: add batch face embeddings response schema --- app/schema/response/staff/face_embeddings.py | 46 ++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 app/schema/response/staff/face_embeddings.py diff --git a/app/schema/response/staff/face_embeddings.py b/app/schema/response/staff/face_embeddings.py new file mode 100644 index 0000000..46043ce --- /dev/null +++ b/app/schema/response/staff/face_embeddings.py @@ -0,0 +1,46 @@ +from uuid import UUID + +from pydantic import BaseModel + +from app.service.batch_face_embedding import BatchFaceEmbeddingSummary, BatchImageResult + + +class BatchFaceEmbeddingResultSchema(BaseModel): + photo_id: UUID + source_type: str + source: str + faces_detected: int + faces_stored: int + errors: list[str] + + @classmethod + def from_result(cls, result: BatchImageResult) -> "BatchFaceEmbeddingResultSchema": + return cls( + photo_id=result.photo_id, + source_type=result.source_type, + source=result.source, + faces_detected=result.faces_detected, + faces_stored=result.faces_stored, + errors=result.errors, + ) + + +class BatchFaceEmbeddingResponse(BaseModel): + total_images: int + total_faces_detected: int + total_faces_stored: int + failures: int + results: list[BatchFaceEmbeddingResultSchema] + + @classmethod + def from_summary(cls, summary: BatchFaceEmbeddingSummary) -> "BatchFaceEmbeddingResponse": + return cls( + total_images=summary.total_images, + total_faces_detected=summary.total_faces_detected, + total_faces_stored=summary.total_faces_stored, + failures=summary.failures, + results=[ + BatchFaceEmbeddingResultSchema.from_result(result) + for result in summary.results + ], + ) From e5f2e0aa2daefa5df1403677aa517288fead83d5 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sat, 21 Mar 2026 19:50:06 +0100 Subject: [PATCH 10/47] fix: match photo_faces upsert signature --- app/service/batch_face_embedding.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/service/batch_face_embedding.py b/app/service/batch_face_embedding.py index 5a13d88..1b87d57 100644 --- a/app/service/batch_face_embedding.py +++ b/app/service/batch_face_embedding.py @@ -250,12 +250,10 @@ async def _store_face( } embedding_literal = self._vector_literal(face.embedding) return await self.photo_face_querier.upsert_photo_face( - arg=photo_face_queries.UpsertPhotoFaceParams( - photo_id=photo_id, - face_index=face_index, - embedding=embedding_literal, - bbox=json.dumps(bbox_payload), - ) + photo_id=photo_id, + face_index=face_index, + dollar_3=embedding_literal, + bbox=json.dumps(bbox_payload), ) @staticmethod From a57f82982323e913b726497020c5e47cf1a0053a Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:28:04 +0100 Subject: [PATCH 11/47] fix: commit/rollback per face and serialize bbox floats --- app/service/batch_face_embedding.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/app/service/batch_face_embedding.py b/app/service/batch_face_embedding.py index 1b87d57..4597dee 100644 --- a/app/service/batch_face_embedding.py +++ b/app/service/batch_face_embedding.py @@ -145,9 +145,11 @@ async def process_batch( ) if stored is None: raise AppException.internal_error("Failed to store face embedding") + await self._commit_best_effort() faces_stored += 1 total_faces_stored += 1 except IntegrityError as exc: + await self._rollback_best_effort() logger.warning( "Failed to store face %s for photo %s: %s", face_index, @@ -156,6 +158,7 @@ async def process_batch( ) errors.append(f"face {face_index}: {exc}") except Exception as exc: + await self._rollback_best_effort() logger.warning( "Failed to store face %s for photo %s: %s", face_index, @@ -243,10 +246,10 @@ async def _store_face( face: DetectedFace, ) -> models.PhotoFace | None: bbox_payload = { - "x1": face.bbox[0], - "y1": face.bbox[1], - "x2": face.bbox[2], - "y2": face.bbox[3], + "x1": float(face.bbox[0]), + "y1": float(face.bbox[1]), + "x2": float(face.bbox[2]), + "y2": float(face.bbox[3]), } embedding_literal = self._vector_literal(face.embedding) return await self.photo_face_querier.upsert_photo_face( @@ -256,6 +259,24 @@ async def _store_face( bbox=json.dumps(bbox_payload), ) + async def _rollback_best_effort(self) -> None: + conn = getattr(self.photo_face_querier, "_conn", None) + if conn is None: + return + try: + await conn.rollback() + except Exception: + pass + + async def _commit_best_effort(self) -> None: + conn = getattr(self.photo_face_querier, "_conn", None) + if conn is None: + return + try: + await conn.commit() + except Exception: + pass + @staticmethod def _vector_literal(embedding: Sequence[float]) -> str: return "[" + ", ".join(str(x) for x in embedding) + "]" From 270b608457a01589a9993ce5f16ce358c6f292cf Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:30:38 +0100 Subject: [PATCH 12/47] feat: wire batch queue service into container --- app/container.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/container.py b/app/container.py index c8c8c1c..35adfb7 100644 --- a/app/container.py +++ b/app/container.py @@ -6,6 +6,7 @@ from app.infra.redis import RedisClient from app.service.device import DeviceService from app.service.batch_face_embedding import BatchFaceEmbeddingService +from app.service.batch_face_embedding_queue import BatchFaceEmbeddingQueueService from app.service.face_embedding import FaceEmbeddingService from app.service.session import SessionService from app.service.staged_upload_storage import StagedUploadStorageService @@ -124,6 +125,8 @@ def __init__( >>>>>>> 08a1d9f (feat: wire batch face embedding service in container) ) + self.batch_face_embedding_queue_service = BatchFaceEmbeddingQueueService() + self.staff_user_service = StaffUserService() self.staff_user_service.init( From 99ca01fbb222c4602ef3f74e9aa83958b34ecf2b Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:30:49 +0100 Subject: [PATCH 13/47] chore: add face-embedding stream settings --- app/core/config.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 2adf52e..248e7f0 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,4 +1,4 @@ -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): @@ -16,6 +16,8 @@ class Settings(BaseSettings): NATS_HOST: str NATS_PASSWORD: str NATS_USER: str + NATS_FACE_EMBEDDING_STREAM: str = "face_embeddings" + NATS_FACE_EMBEDDING_DURABLE: str = "face_embeddings_worker" # MinIO @@ -55,9 +57,10 @@ class Settings(BaseSettings): FACE_ENCRYPTION_KEY: str FIREBASE_CREDENTIALS_PATH: str = "multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json" - class Config: - env_file = ".env" - extra = "ignore" + model_config = SettingsConfigDict( + env_file=".env", + extra="ignore", + ) settings = Settings() # type: ignore From 7698d368e5c4649de4be138b384d9d131e9991df Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:30:59 +0100 Subject: [PATCH 14/47] feat: add batch embeddings subject and ensure stream --- app/infra/nats.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/app/infra/nats.py b/app/infra/nats.py index cf5b91e..784a2ef 100644 --- a/app/infra/nats.py +++ b/app/infra/nats.py @@ -2,7 +2,8 @@ from typing import Any, Callable, Optional from nats.aio.client import Client as NATS from nats.js.client import JetStreamContext -from nats.js.api import DeliverPolicy, AckPolicy +from nats.js.api import DeliverPolicy, AckPolicy, StreamConfig +from nats.js.errors import NotFoundError from nats.aio.msg import Msg from pydantic import BaseModel @@ -28,6 +29,7 @@ class NatsSubjects(Enum): STAFF_UPLOAD_REQUEST_CREATED = "staff.upload_request.created" STAFF_UPLOAD_REQUEST_APPROVED = "staff.upload_request.approved" STAFF_UPLOAD_REQUEST_REJECTED = "staff.upload_request.rejected" + BATCH_FACE_EMBEDDINGS_REQUESTED = "photo_faces.batch.requested" class NatsClient: _nc: Optional[NATS] = None @@ -102,6 +104,8 @@ async def js_subscribe( if NatsClient._js is None: await NatsClient.connect() + await NatsClient.ensure_stream(stream_name=stream_name, subjects=[subject.value]) + async def _wrapper(msg: Msg) -> None: await callback(msg.data) await msg.ack() @@ -116,3 +120,20 @@ async def _wrapper(msg: Msg) -> None: deliver_policy=DeliverPolicy.NEW, # ack_policy=ack_policy ) + + @staticmethod + async def ensure_stream(*, stream_name: str, subjects: list[str]) -> None: + if NatsClient._js is None: + await NatsClient.connect() + js = NatsClient._js + assert js is not None + try: + await js.stream_info(stream_name) + except NotFoundError: + await js.add_stream( + name=stream_name, + config=StreamConfig( + name=stream_name, + subjects=subjects, + ), + ) From f1635e8cae6ae39612168ea381b865cc940593a0 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:31:06 +0100 Subject: [PATCH 15/47] feat: switch batch endpoint to enqueue jobs (202) --- app/router/staff/face_embeddings.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/app/router/staff/face_embeddings.py b/app/router/staff/face_embeddings.py index 1b60335..6387d46 100644 --- a/app/router/staff/face_embeddings.py +++ b/app/router/staff/face_embeddings.py @@ -3,21 +3,29 @@ from app.container import Container, get_container from app.deps.cookie_auth import get_current_staff_user from app.schema.request.staff.face_embeddings import BatchFaceEmbeddingsRequest -from app.schema.response.staff.face_embeddings import BatchFaceEmbeddingResponse +from app.schema.response.staff.face_embeddings import BatchFaceEmbeddingEnqueueResponse from db.generated.models import StaffUser router = APIRouter(prefix="/batch") -@router.post("/face-embeddings", response_model=BatchFaceEmbeddingResponse) +@router.post( + "/face-embeddings", + response_model=BatchFaceEmbeddingEnqueueResponse, + status_code=202, +) async def batch_face_embeddings( req: BatchFaceEmbeddingsRequest, current_staff_user: StaffUser = Depends(get_current_staff_user), container: Container = Depends(get_container), -) -> BatchFaceEmbeddingResponse: - summary = await container.batch_face_embedding_service.process_batch( +) -> BatchFaceEmbeddingEnqueueResponse: + job = await container.batch_face_embedding_queue_service.enqueue( items=req.to_inputs(), staff_user_id=current_staff_user.id, ) - return BatchFaceEmbeddingResponse.from_summary(summary) + return BatchFaceEmbeddingEnqueueResponse( + job_id=job.job_id, + queued=len(job.items), + submitted_at=job.submitted_at, + ) From 1b87d5ab1c658e82db0eaa799c7ce65100dd12cb Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:31:12 +0100 Subject: [PATCH 16/47] feat: add batch face embedding request DTO --- app/schema/request/staff/face_embeddings.py | 24 ++++----------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/app/schema/request/staff/face_embeddings.py b/app/schema/request/staff/face_embeddings.py index 6d9e88b..cd2a0db 100644 --- a/app/schema/request/staff/face_embeddings.py +++ b/app/schema/request/staff/face_embeddings.py @@ -1,32 +1,16 @@ -from typing import Literal -from uuid import UUID - from pydantic import BaseModel, Field -from app.service.batch_face_embedding import BatchImageInput +from app.schema.dto.face_embeddings import BatchFaceEmbeddingItem MAX_BATCH_SIZE = 200 -class BatchFaceEmbeddingItemRequest(BaseModel): - photo_id: UUID - source_type: Literal["drive", "minio", "local"] - source: str = Field(min_length=1, max_length=2048) - - def to_input(self) -> BatchImageInput: - return BatchImageInput( - photo_id=self.photo_id, - source_type=self.source_type, - source=self.source, - ) - - class BatchFaceEmbeddingsRequest(BaseModel): - items: list[BatchFaceEmbeddingItemRequest] = Field( + items: list[BatchFaceEmbeddingItem] = Field( min_length=1, max_length=MAX_BATCH_SIZE, ) - def to_inputs(self) -> list[BatchImageInput]: - return [item.to_input() for item in self.items] + def to_inputs(self) -> list[BatchFaceEmbeddingItem]: + return self.items From 48c0e03c8f592926bdb03acdb292defeb70cf74c Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:32:58 +0100 Subject: [PATCH 17/47] feat: add batch face embedding enqueue response --- app/schema/response/staff/face_embeddings.py | 46 +++----------------- 1 file changed, 5 insertions(+), 41 deletions(-) diff --git a/app/schema/response/staff/face_embeddings.py b/app/schema/response/staff/face_embeddings.py index 46043ce..5946b0a 100644 --- a/app/schema/response/staff/face_embeddings.py +++ b/app/schema/response/staff/face_embeddings.py @@ -1,46 +1,10 @@ +from datetime import datetime from uuid import UUID from pydantic import BaseModel -from app.service.batch_face_embedding import BatchFaceEmbeddingSummary, BatchImageResult - -class BatchFaceEmbeddingResultSchema(BaseModel): - photo_id: UUID - source_type: str - source: str - faces_detected: int - faces_stored: int - errors: list[str] - - @classmethod - def from_result(cls, result: BatchImageResult) -> "BatchFaceEmbeddingResultSchema": - return cls( - photo_id=result.photo_id, - source_type=result.source_type, - source=result.source, - faces_detected=result.faces_detected, - faces_stored=result.faces_stored, - errors=result.errors, - ) - - -class BatchFaceEmbeddingResponse(BaseModel): - total_images: int - total_faces_detected: int - total_faces_stored: int - failures: int - results: list[BatchFaceEmbeddingResultSchema] - - @classmethod - def from_summary(cls, summary: BatchFaceEmbeddingSummary) -> "BatchFaceEmbeddingResponse": - return cls( - total_images=summary.total_images, - total_faces_detected=summary.total_faces_detected, - total_faces_stored=summary.total_faces_stored, - failures=summary.failures, - results=[ - BatchFaceEmbeddingResultSchema.from_result(result) - for result in summary.results - ], - ) +class BatchFaceEmbeddingEnqueueResponse(BaseModel): + job_id: UUID + queued: int + submitted_at: datetime From 6bf86c0c915b362a4847b751466e799e513d6b3f Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:33:06 +0100 Subject: [PATCH 18/47] feat: add batch face embedding job DTO --- app/schema/dto/face_embeddings.py | 32 +++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 app/schema/dto/face_embeddings.py diff --git a/app/schema/dto/face_embeddings.py b/app/schema/dto/face_embeddings.py new file mode 100644 index 0000000..4be79f9 --- /dev/null +++ b/app/schema/dto/face_embeddings.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Literal +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field + +from app.service.batch_face_embedding import BatchImageInput + + +class BatchFaceEmbeddingItem(BaseModel): + photo_id: UUID + source_type: Literal["drive", "minio", "local"] + source: str = Field(min_length=1, max_length=2048) + + def to_input(self) -> BatchImageInput: + return BatchImageInput( + photo_id=self.photo_id, + source_type=self.source_type, + source=self.source, + ) + + +class BatchFaceEmbeddingJob(BaseModel): + job_id: UUID = Field(default_factory=uuid4) + staff_user_id: UUID + items: list[BatchFaceEmbeddingItem] + submitted_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_inputs(self) -> list[BatchImageInput]: + return [item.to_input() for item in self.items] From 3558fd97d7d2f8d9664dcbb8a11aa3115ba3e41f Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:33:12 +0100 Subject: [PATCH 19/47] feat: enqueue batch face embedding jobs to JetStream --- app/service/batch_face_embedding_queue.py | 43 +++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 app/service/batch_face_embedding_queue.py diff --git a/app/service/batch_face_embedding_queue.py b/app/service/batch_face_embedding_queue.py new file mode 100644 index 0000000..f72be5c --- /dev/null +++ b/app/service/batch_face_embedding_queue.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from collections.abc import Sequence +import json +from uuid import UUID + +from app.core.config import settings +from app.core.exceptions import AppException +from app.core.logger import logger +from app.infra.nats import NatsClient, NatsSubjects +from app.schema.dto.face_embeddings import ( + BatchFaceEmbeddingItem, + BatchFaceEmbeddingJob, +) + + +class BatchFaceEmbeddingQueueService: + def __init__(self) -> None: + self.stream_name = settings.NATS_FACE_EMBEDDING_STREAM + + async def enqueue( + self, + *, + items: Sequence[BatchFaceEmbeddingItem], + staff_user_id: UUID, + ) -> BatchFaceEmbeddingJob: + job = BatchFaceEmbeddingJob( + staff_user_id=staff_user_id, + items=list(items), + ) + + payload = job.model_dump(mode="json") + try: + await NatsClient.js_publish( + subject=NatsSubjects.BATCH_FACE_EMBEDDINGS_REQUESTED, + message=json.dumps(payload).encode("utf-8"), + stream_name=self.stream_name, + ) + except Exception as exc: + logger.warning("Failed to enqueue batch face embedding job: %s", exc) + raise AppException.internal_error("Failed to enqueue batch face embedding job") from exc + + return job From 1983bc9f192c036ff9669fbb6acd4239aade80e4 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:33:28 +0100 Subject: [PATCH 20/47] feat: add JetStream worker for batch face embeddings --- app/worker/batch_face_embeddings.py | 55 +++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 app/worker/batch_face_embeddings.py diff --git a/app/worker/batch_face_embeddings.py b/app/worker/batch_face_embeddings.py new file mode 100644 index 0000000..83325bd --- /dev/null +++ b/app/worker/batch_face_embeddings.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import asyncio + +from app.container import Container +from app.core.config import settings +from app.core.logger import logger +from app.infra.database import engine +from app.infra.minio import init_minio_client +from app.infra.nats import NatsClient, NatsSubjects +from app.infra.redis import RedisClient +from app.schema.dto.face_embeddings import BatchFaceEmbeddingJob + + +class BatchFaceEmbeddingWorker: + def __init__(self, container: Container) -> None: + self.container = container + + async def handle_message(self, data: bytes) -> None: + job = BatchFaceEmbeddingJob.model_validate_json(data) + await self.container.batch_face_embedding_service.process_batch( + items=job.to_inputs(), + staff_user_id=job.staff_user_id, + ) + + +async def run_worker() -> None: + await init_minio_client( + minio_host=settings.MINIO_HOST, + minio_port=settings.MINIO_API_PORT, + minio_root_user=settings.MINIO_ROOT_USER, + minio_root_password=settings.MINIO_ROOT_PASSWORD, + ) + RedisClient( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + password=settings.REDIS_PASSWORD, + ) + async with engine.connect() as conn: + container = Container(conn) + worker = BatchFaceEmbeddingWorker(container) + + await NatsClient.js_subscribe( + subject=NatsSubjects.BATCH_FACE_EMBEDDINGS_REQUESTED, + callback=worker.handle_message, + stream_name=settings.NATS_FACE_EMBEDDING_STREAM, + durable_name=settings.NATS_FACE_EMBEDDING_DURABLE, + ) + + logger.info("BatchFaceEmbeddingWorker subscribed; waiting for jobs") + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(run_worker()) From f7d03941e2e1a351a20611c02823be7238267129 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:33:34 +0100 Subject: [PATCH 21/47] chore: migrate photo_faces embedding to 512 dims --- ...b4a3d21_alter_photo_faces_embedding_dim.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 migrations/versions/9f6c1b4a3d21_alter_photo_faces_embedding_dim.py diff --git a/migrations/versions/9f6c1b4a3d21_alter_photo_faces_embedding_dim.py b/migrations/versions/9f6c1b4a3d21_alter_photo_faces_embedding_dim.py new file mode 100644 index 0000000..86df9cc --- /dev/null +++ b/migrations/versions/9f6c1b4a3d21_alter_photo_faces_embedding_dim.py @@ -0,0 +1,24 @@ +"""alter photo_faces embedding dimension to 512 + +Revision ID: 9f6c1b4a3d21 +Revises: 5ead72a95638 +Create Date: 2026-03-21 23:23:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "9f6c1b4a3d21" +down_revision: Union[str, Sequence[str], None] = "5ead72a95638" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TABLE photo_faces ALTER COLUMN embedding TYPE vector(512);") + + +def downgrade() -> None: + op.execute("ALTER TABLE photo_faces ALTER COLUMN embedding TYPE vector(1536);") From 944ab28302aee6ce43c0d004722f2db5104c8f18 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:33:42 +0100 Subject: [PATCH 22/47] chore: merge alembic heads --- .../versions/4dd6658b9f83_merge_heads.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 migrations/versions/4dd6658b9f83_merge_heads.py diff --git a/migrations/versions/4dd6658b9f83_merge_heads.py b/migrations/versions/4dd6658b9f83_merge_heads.py new file mode 100644 index 0000000..b63cff0 --- /dev/null +++ b/migrations/versions/4dd6658b9f83_merge_heads.py @@ -0,0 +1,28 @@ +"""merge heads + +Revision ID: 4dd6658b9f83 +Revises: 9f6c1b4a3d21, c3b8d0f1e2a4 +Create Date: 2026-03-21 23:29:09.967007 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4dd6658b9f83' +down_revision: Union[str, Sequence[str], None] = ('9f6c1b4a3d21', 'c3b8d0f1e2a4') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + pass + + +def downgrade() -> None: + """Downgrade schema.""" + pass From c9da8d96033cdfc4b0f4deb9c7f1d3bc5f34a39c Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:39:52 +0100 Subject: [PATCH 23/47] chore: add sql up/down for photo_faces embedding dim change --- migrations/sql/down/alter-photo-faces-embedding-dim.sql | 2 ++ migrations/sql/up/alter-photo-faces-embedding-dim.sql | 2 ++ 2 files changed, 4 insertions(+) create mode 100644 migrations/sql/down/alter-photo-faces-embedding-dim.sql create mode 100644 migrations/sql/up/alter-photo-faces-embedding-dim.sql diff --git a/migrations/sql/down/alter-photo-faces-embedding-dim.sql b/migrations/sql/down/alter-photo-faces-embedding-dim.sql new file mode 100644 index 0000000..f3be603 --- /dev/null +++ b/migrations/sql/down/alter-photo-faces-embedding-dim.sql @@ -0,0 +1,2 @@ +ALTER TABLE photo_faces +ALTER COLUMN embedding TYPE vector(1536); diff --git a/migrations/sql/up/alter-photo-faces-embedding-dim.sql b/migrations/sql/up/alter-photo-faces-embedding-dim.sql new file mode 100644 index 0000000..6538447 --- /dev/null +++ b/migrations/sql/up/alter-photo-faces-embedding-dim.sql @@ -0,0 +1,2 @@ +ALTER TABLE photo_faces +ALTER COLUMN embedding TYPE vector(512); From cc5bd49f683da05ad1191229516513468f9c83f3 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:48:57 +0100 Subject: [PATCH 24/47] chore: centralize shared content-type and url constants --- app/core/constant.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/core/constant.py b/app/core/constant.py index 9f1f512..06f39af 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -32,6 +32,10 @@ class AuditEventType(str, Enum): "image/heif" } +DEFAULT_CONTENT_TYPE = "application/octet-stream" +DRIVE_ALLOWED_HOSTS = {"drive.google.com", "docs.google.com"} +MINIO_URL_PREFIX = "minio://" + MAX_IMAGE_SIZE = 5 * 1024 * 1024 MIN_ENROLL_IMAGES = 3 MAX_ENROLL_IMAGES = 5 From 1b005de9fd9bb81496dce47d16aede93d442acea Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:48:57 +0100 Subject: [PATCH 25/47] chore: use default content type constant in MinIO --- app/infra/minio.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/infra/minio.py b/app/infra/minio.py index 09104ea..2df0f2e 100644 --- a/app/infra/minio.py +++ b/app/infra/minio.py @@ -9,6 +9,7 @@ from app.core.utils import check_extension from app.core.exceptions import AppException +from app.core.constant import DEFAULT_CONTENT_TYPE IMAGES_BUCKET_NAME = "images" @@ -48,7 +49,7 @@ async def put(self, file: UploadFile, object_name: str | None = None) -> str: object_name = str(uuid.uuid4()) if file.content_type is None: - file.content_type = "application/octet-stream" + file.content_type = DEFAULT_CONTENT_TYPE if file.filename is None: file.filename = object_name @@ -80,7 +81,7 @@ async def get(self, object_name: str) -> tuple[bytes, str, str]: data = await res.read() content_type = ( - res.content_type if res.content_type else "application/octet-stream" + res.content_type if res.content_type else DEFAULT_CONTENT_TYPE ) filename = res.headers.get("x-amz-meta-filename", f"{object_name}") From b145a892a5b9ebcc0b54afb516f8cb46a92f8989 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:48:57 +0100 Subject: [PATCH 26/47] chore: use default content type constant in enrollment --- app/router/mobile/enrollement.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 109dfda..1a5f652 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -5,7 +5,13 @@ from app.container import Container, get_container from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.core.exceptions import AppException -from app.core.constant import IMAGE_ALLOWED_TYPES, MAX_ENROLL_IMAGES, MAX_IMAGE_SIZE, MIN_ENROLL_IMAGES +from app.core.constant import ( + DEFAULT_CONTENT_TYPE, + IMAGE_ALLOWED_TYPES, + MAX_ENROLL_IMAGES, + MAX_IMAGE_SIZE, + MIN_ENROLL_IMAGES, +) from app.service.face_embedding import FaceImagePayload from db.generated.models import User @@ -57,7 +63,7 @@ async def enroll_face( payload: FaceImagePayload = FaceImagePayload( filename=file.filename or "unknown", - content_type=file.content_type or "application/octet-stream", + content_type=file.content_type or DEFAULT_CONTENT_TYPE, bytes=contents, ) From 2587c412df3d17d04e48a162d5334544a61fa4e7 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 00:48:57 +0100 Subject: [PATCH 27/47] chore: use constants for content type and source parsing --- app/service/batch_face_embedding.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/service/batch_face_embedding.py b/app/service/batch_face_embedding.py index 4597dee..ae17510 100644 --- a/app/service/batch_face_embedding.py +++ b/app/service/batch_face_embedding.py @@ -13,6 +13,7 @@ from app.core.exceptions import AppException from app.core.logger import logger +from app.core.constant import DEFAULT_CONTENT_TYPE, DRIVE_ALLOWED_HOSTS, MINIO_URL_PREFIX from app.infra.google_drive import GoogleDriveClient, GoogleDriveFileDownload from app.infra.minio import Bucket, IMAGES_BUCKET_NAME from app.service.face_embedding import DetectedFace, FaceEmbeddingService, FaceImagePayload @@ -231,7 +232,7 @@ def _load_from_local(self, source: str) -> FaceImagePayload: if not path.exists() or not path.is_file(): raise AppException.not_found(f"Local file not found: {path}") data = path.read_bytes() - content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream" + content_type = mimetypes.guess_type(path.name)[0] or DEFAULT_CONTENT_TYPE return FaceImagePayload( filename=path.name, content_type=content_type, @@ -294,7 +295,7 @@ def _payload_from_drive(downloaded: GoogleDriveFileDownload) -> FaceImagePayload def _extract_drive_file_id(source: str) -> str: if source.startswith("http://") or source.startswith("https://"): parsed = urlparse(source) - if "drive.google.com" not in parsed.netloc and "docs.google.com" not in parsed.netloc: + if not any(host in parsed.netloc for host in DRIVE_ALLOWED_HOSTS): raise AppException.bad_request("Invalid Google Drive URL") query_id = parse_qs(parsed.query).get("id") if query_id and query_id[0]: @@ -309,8 +310,8 @@ def _extract_drive_file_id(source: str) -> str: @staticmethod def _parse_minio_source(source: str) -> tuple[str, str]: - if source.startswith("minio://"): - raw = source[len("minio://") :] + if source.startswith(MINIO_URL_PREFIX): + raw = source[len(MINIO_URL_PREFIX) :] parts = raw.split("/", 1) if len(parts) != 2 or not parts[0] or not parts[1]: raise AppException.bad_request("Invalid MinIO source format") From 99c8bc9ec2b67b0b8cd35d47796b1fb4743a6390 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 11:31:00 +0100 Subject: [PATCH 28/47] fix: refactor batch face embedding flow for clarity --- app/service/batch_face_embedding.py | 212 +++++++++++++++------------- 1 file changed, 113 insertions(+), 99 deletions(-) diff --git a/app/service/batch_face_embedding.py b/app/service/batch_face_embedding.py index ae17510..9e0c615 100644 --- a/app/service/batch_face_embedding.py +++ b/app/service/batch_face_embedding.py @@ -72,120 +72,134 @@ async def process_batch( if not items: raise AppException.bad_request("At least one image is required") - access_token: str | None = None - if any(item.source_type == "drive" for item in items): - if staff_user_id is None: - raise AppException.bad_request("staff_user_id is required for drive sources") - access_token = await self.staff_drive_service.get_access_token_for_staff_user( - staff_user_id - ) - + access_token = await self._get_access_token(items, staff_user_id) results: list[BatchImageResult] = [] total_faces_detected = 0 total_faces_stored = 0 for item in items: - errors: list[str] = [] - faces_detected = 0 - faces_stored = 0 + result, faces_detected, faces_stored = await self._process_item( + item, + access_token, + ) + total_faces_detected += faces_detected + total_faces_stored += faces_stored + results.append(result) + + failures = sum(1 for result in results if result.errors) + return BatchFaceEmbeddingSummary( + total_images=len(items), + total_faces_detected=total_faces_detected, + total_faces_stored=total_faces_stored, + failures=failures, + results=results, + ) + + async def _get_access_token( + self, + items: Sequence[BatchImageInput], + staff_user_id: uuid.UUID | None, + ) -> str | None: + if not any(item.source_type == "drive" for item in items): + return None + if staff_user_id is None: + raise AppException.bad_request("staff_user_id is required for drive sources") + return await self.staff_drive_service.get_access_token_for_staff_user( + staff_user_id + ) + + async def _process_item( + self, + item: BatchImageInput, + access_token: str | None, + ) -> tuple[BatchImageResult, int, int]: + errors: list[str] = [] + try: + payload = await self._load_payload(item, access_token) + except Exception as exc: + logger.warning( + "Failed to load image for photo %s (%s): %s", + item.photo_id, + item.source, + exc, + ) + errors.append(str(exc)) + return self._build_result(item, 0, 0, errors), 0, 0 + try: + faces = await self.face_embedding_service.detect_faces(payload) + except Exception as exc: + logger.warning( + "Face detection failed for photo %s: %s", + item.photo_id, + exc, + ) + errors.append(str(exc)) + return self._build_result(item, 0, 0, errors), 0, 0 + + faces_detected = len(faces) + if not faces: + errors.append("No faces detected") + + faces_stored = await self._store_faces(item.photo_id, faces, errors) + return ( + self._build_result(item, faces_detected, faces_stored, errors), + faces_detected, + faces_stored, + ) + + async def _store_faces( + self, + photo_id: uuid.UUID, + faces: Sequence[DetectedFace], + errors: list[str], + ) -> int: + faces_stored = 0 + for face_index, face in enumerate(faces): try: - payload = await self._load_payload(item, access_token) - except Exception as exc: + stored = await self._store_face( + photo_id=photo_id, + face_index=face_index, + face=face, + ) + if stored is None: + raise AppException.internal_error("Failed to store face embedding") + await self._commit_best_effort() + faces_stored += 1 + except IntegrityError as exc: + await self._rollback_best_effort() logger.warning( - "Failed to load image for photo %s (%s): %s", - item.photo_id, - item.source, + "Failed to store face %s for photo %s: %s", + face_index, + photo_id, exc, ) - errors.append(str(exc)) - results.append( - BatchImageResult( - photo_id=item.photo_id, - source_type=item.source_type, - source=item.source, - faces_detected=0, - faces_stored=0, - errors=errors, - ) - ) - continue - - try: - faces = await self.face_embedding_service.detect_faces(payload) - faces_detected = len(faces) - total_faces_detected += faces_detected + errors.append(f"face {face_index}: {exc}") except Exception as exc: + await self._rollback_best_effort() logger.warning( - "Face detection failed for photo %s: %s", - item.photo_id, + "Failed to store face %s for photo %s: %s", + face_index, + photo_id, exc, ) - errors.append(str(exc)) - results.append( - BatchImageResult( - photo_id=item.photo_id, - source_type=item.source_type, - source=item.source, - faces_detected=0, - faces_stored=0, - errors=errors, - ) - ) - continue - - if not faces: - errors.append("No faces detected") - - for face_index, face in enumerate(faces): - try: - stored = await self._store_face( - photo_id=item.photo_id, - face_index=face_index, - face=face, - ) - if stored is None: - raise AppException.internal_error("Failed to store face embedding") - await self._commit_best_effort() - faces_stored += 1 - total_faces_stored += 1 - except IntegrityError as exc: - await self._rollback_best_effort() - logger.warning( - "Failed to store face %s for photo %s: %s", - face_index, - item.photo_id, - exc, - ) - errors.append(f"face {face_index}: {exc}") - except Exception as exc: - await self._rollback_best_effort() - logger.warning( - "Failed to store face %s for photo %s: %s", - face_index, - item.photo_id, - exc, - ) - errors.append(f"face {face_index}: {exc}") - - results.append( - BatchImageResult( - photo_id=item.photo_id, - source_type=item.source_type, - source=item.source, - faces_detected=faces_detected, - faces_stored=faces_stored, - errors=errors, - ) - ) + errors.append(f"face {face_index}: {exc}") + return faces_stored - failures = sum(1 for result in results if result.errors) - return BatchFaceEmbeddingSummary( - total_images=len(items), - total_faces_detected=total_faces_detected, - total_faces_stored=total_faces_stored, - failures=failures, - results=results, + @staticmethod + def _build_result( + item: BatchImageInput, + faces_detected: int, + faces_stored: int, + errors: list[str], + ) -> BatchImageResult: + return BatchImageResult( + photo_id=item.photo_id, + source_type=item.source_type, + source=item.source, + faces_detected=faces_detected, + faces_stored=faces_stored, + errors=errors, ) async def _load_payload( From 2996d8da1ca5756e8f9bdfebd8343046dbe59099 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Sun, 22 Mar 2026 11:31:08 +0100 Subject: [PATCH 29/47] chore: update generated db queriers --- db/generated/models.py | 1 + db/generated/user.py | 62 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/db/generated/models.py b/db/generated/models.py index 1111a86..f54caf5 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -223,6 +223,7 @@ class User: face_embedding: Optional[Any] blocked: bool deleted_at: Optional[datetime.datetime] + blocked: bool @dataclasses.dataclass() diff --git a/db/generated/user.py b/db/generated/user.py index 823be6a..ddb8ffc 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -14,7 +14,11 @@ CREATE_USER = """-- name: create_user \\:one INSERT INTO users (email, hashed_password) VALUES (:p1, :p2) -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at +<<<<<<< HEAD +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +======= +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +>>>>>>> 7ef7381 (chore: update generated db queriers) """ @@ -25,21 +29,33 @@ GET_USER_BY_EMAIL = """-- name: get_user_by_email \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at +<<<<<<< HEAD +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +======= +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +>>>>>>> 7ef7381 (chore: update generated db queriers) FROM users WHERE email = :p1 """ GET_USER_BY_ID = """-- name: get_user_by_id \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at +<<<<<<< HEAD +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +======= +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +>>>>>>> 7ef7381 (chore: update generated db queriers) FROM users WHERE id = :p1 """ LIST_USERS = """-- name: list_users \\:many -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at +<<<<<<< HEAD +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +======= +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +>>>>>>> 7ef7381 (chore: update generated db queriers) FROM users ORDER BY created_at DESC LIMIT :p1 OFFSET :p2 @@ -60,6 +76,7 @@ SET face_embedding = :p1\\:\\:vector, updated_at = NOW() WHERE id = :p2 +<<<<<<< HEAD RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at """ @@ -72,6 +89,9 @@ updated_at = NOW() WHERE id = :p4 RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at +======= +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +>>>>>>> 7ef7381 (chore: update generated db queriers) """ @@ -80,7 +100,11 @@ SET hashed_password = :p1, updated_at = NOW() WHERE id = :p2 +<<<<<<< HEAD RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at +======= +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +>>>>>>> 7ef7381 (chore: update generated db queriers) """ @@ -100,8 +124,13 @@ async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Op updated_at=row[4], display_name=row[5], face_embedding=row[6], +<<<<<<< HEAD blocked=row[7], deleted_at=row[8], +======= + deleted_at=row[7], + blocked=row[8], +>>>>>>> 7ef7381 (chore: update generated db queriers) ) async def delete_user(self, *, id: uuid.UUID) -> None: @@ -119,8 +148,13 @@ async def get_user_by_email(self, *, email: str) -> Optional[models.User]: updated_at=row[4], display_name=row[5], face_embedding=row[6], +<<<<<<< HEAD blocked=row[7], deleted_at=row[8], +======= + deleted_at=row[7], + blocked=row[8], +>>>>>>> 7ef7381 (chore: update generated db queriers) ) async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -135,8 +169,13 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: updated_at=row[4], display_name=row[5], face_embedding=row[6], +<<<<<<< HEAD blocked=row[7], deleted_at=row[8], +======= + deleted_at=row[7], + blocked=row[8], +>>>>>>> 7ef7381 (chore: update generated db queriers) ) async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: @@ -150,8 +189,13 @@ async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.U updated_at=row[4], display_name=row[5], face_embedding=row[6], +<<<<<<< HEAD blocked=row[7], deleted_at=row[8], +======= + deleted_at=row[7], + blocked=row[8], +>>>>>>> 7ef7381 (chore: update generated db queriers) ) async def set_user_blocked(self, *, blocked: bool, id: uuid.UUID) -> Optional[models.User]: @@ -182,6 +226,7 @@ async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[ updated_at=row[4], display_name=row[5], face_embedding=row[6], +<<<<<<< HEAD blocked=row[7], deleted_at=row[8], ) @@ -205,6 +250,10 @@ async def update_user(self, *, email: str, display_name: Optional[str], blocked: face_embedding=row[6], blocked=row[7], deleted_at=row[8], +======= + deleted_at=row[7], + blocked=row[8], +>>>>>>> 7ef7381 (chore: update generated db queriers) ) async def update_user_password(self, *, hashed_password: Optional[str], id: uuid.UUID) -> Optional[models.User]: @@ -219,6 +268,11 @@ async def update_user_password(self, *, hashed_password: Optional[str], id: uuid updated_at=row[4], display_name=row[5], face_embedding=row[6], +<<<<<<< HEAD blocked=row[7], deleted_at=row[8], +======= + deleted_at=row[7], + blocked=row[8], +>>>>>>> 7ef7381 (chore: update generated db queriers) ) From 2c9b6d15e4d8f04f3abce72b871e6f9f8757de42 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 03:27:43 +0100 Subject: [PATCH 30/47] Add token blacklist and blocked checks in auth --- app/core/constant.py | 3 + app/core/token_blacklist.py | 23 +++++ app/deps/token_auth.py | 4 + app/service/users.py | 165 +++++++++++++++++++++++++++++------- 4 files changed, 163 insertions(+), 32 deletions(-) create mode 100644 app/core/token_blacklist.py diff --git a/app/core/constant.py b/app/core/constant.py index 06f39af..27c2b05 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -4,6 +4,7 @@ class RedisKey(str, Enum): UserSession = "user_session" UserSessionByUser = "user_session:{user_id}" +<<<<<<< HEAD INVALID_TOKEN_SET_KEY= "notifications:invalid_tokens" @@ -23,6 +24,8 @@ class AuditEventType(str, Enum): UPLOAD_REQUEST_REJECTED = "upload_request.rejected" +======= +>>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) BlacklistedSession = "blacklist:session:{session_id}" IMAGE_ALLOWED_TYPES = { diff --git a/app/core/token_blacklist.py b/app/core/token_blacklist.py new file mode 100644 index 0000000..3f22038 --- /dev/null +++ b/app/core/token_blacklist.py @@ -0,0 +1,23 @@ +from datetime import datetime, timezone + +from app.core.constant import RedisKey +from app.infra.redis import RedisClient + + +async def blacklist_session( + redis: RedisClient, + session_id: str, + expires_at: datetime | None = None, +) -> None: + ttl: int | None = None + if expires_at is not None: + ttl = int((expires_at - datetime.now(timezone.utc)).total_seconds()) + if ttl < 0: + ttl = 0 + key = RedisKey.BlacklistedSession.value.format(session_id=session_id) + await redis.set(key, "1", expire=ttl) + + +async def is_session_blacklisted(redis: RedisClient, session_id: str) -> bool: + key = RedisKey.BlacklistedSession.value.format(session_id=session_id) + return await redis.exists(key) diff --git a/app/deps/token_auth.py b/app/deps/token_auth.py index a7eff17..527e28b 100644 --- a/app/deps/token_auth.py +++ b/app/deps/token_auth.py @@ -5,6 +5,7 @@ from pydantic import BaseModel from app.container import get_container, Container from app.core.securite import decode_access_mobile_token +from app.core.token_blacklist import is_session_blacklisted security = HTTPBearer() @@ -31,6 +32,9 @@ async def get_current_mobile_user( session_id = uuid.UUID(session_id_str) + if await is_session_blacklisted(container.redis, session_id_str): + raise HTTPException(status_code=401, detail="Token is blacklisted") + # Validate session via SessionService session = await container.session_service.session_querier.get_session_by_id(id=session_id) if not session: diff --git a/app/service/users.py b/app/service/users.py index eb853da..4eb54a2 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -13,6 +13,7 @@ Get_expiry_time, ) from app.core.config import settings +from app.core.token_blacklist import blacklist_session, is_session_blacklisted from app.infra.redis import RedisClient from app.schema.request.mobile.auth import MobileAuthRequest @@ -158,6 +159,9 @@ async def refresh_token( if not session_id: raise AppException.unauthorized("Invalid refresh token") + if await is_session_blacklisted(redis, session_id): + raise AppException.unauthorized("Token is blacklisted") + session = await self.session_querier.get_session_by_id(id=uuid.UUID(session_id)) if not session: @@ -166,11 +170,27 @@ async def refresh_token( if session.expires_at < datetime.now(timezone.utc): raise AppException.unauthorized("Session expired") +<<<<<<< HEAD +======= + session_key = constant.RedisKey.UserSessionByUser.value.format( + user_id=session.user_id + ) + redis_session = await redis.get(session_key) + + if not redis_session or redis_session != session_id: + raise AppException.unauthorized("Session invalidated") + +>>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) user = await self.user_querier.get_user_by_id(id=session.user_id) if not user: raise AppException.unauthorized("User not found") if user.blocked: raise AppException.forbidden("User is blocked") +<<<<<<< HEAD +======= + + await redis.expire(session_key, AuthService.REDIS_SESSION_TTL) +>>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) new_access_token = create_acces_mobile_token(session_id) new_refresh_token = create_refresh_mobile_token(session_id) @@ -183,44 +203,43 @@ async def refresh_token( expires_in=expiry, ) - async def logout( + async def refresh_token( self, - redis: RedisClient, - user_id: str, - session_id: str, - ) -> dict[str, str]: - session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id) - await redis.delete(session_key) - return {"message": "Logged out successfully"} + refresh_token: str, + ) -> MobileAuthResponse: + payload = decode_refresh_mobile_token(refresh_token) + session_id = payload.get("session_id") - async def add_embbed_user( - self, - user_id: uuid.UUID, - image_payloads: list[FaceImagePayload], - ) ->User: - logger.info("Generating face embeddings for user %s", user_id) + if not session_id: + raise AppException.unauthorized("Invalid refresh token") - averaging = await self.face_embedding_service.compute_average_embedding( - image_payloads - ) - # pgvector accepts input like: "[0.1, 0.2, ...]". Convert list to a vector literal. - vector_literal = "[" + ", ".join(str(x) for x in averaging) + "]" - #TODO:we encrypt it here we wont store it as plaintext in the db but the porblmem is were lossing the search as trade of in the vestor so i will let it like this until i found somthing tht fit - # encrypted_embedding = EmbeddingCrypto.encrypt(averaging) - user = await self.user_querier.set_user_embedding( - dollar_1=vector_literal, - id=user_id, - ) + session = await self.session_querier.get_session_by_id(id=uuid.UUID(session_id)) + + if not session: + raise AppException.unauthorized("Session not found") + + if session.expires_at < datetime.now(timezone.utc): + raise AppException.unauthorized("Session expired") + + user = await self.user_querier.get_user_by_id(id=session.user_id) if not user: - raise AppException.internal_error("Failed to set user embedding") + raise AppException.unauthorized("User not found") + if user.blocked: + raise AppException.forbidden("User is blocked") - return user + new_access_token = create_acces_mobile_token(session_id) + new_refresh_token = create_refresh_mobile_token(session_id) + expiry = Get_expiry_time() + + return MobileAuthResponse( + access_token=new_access_token, + refresh_token=new_refresh_token, + session_id=session_id, + expires_in=expiry, + ) + if await is_session_blacklisted(redis, session_id): + return False - async def validate_session( - self, - redis: RedisClient, - session_id: str, - ) -> bool: session = await self.session_querier.get_session_by_id(id=uuid.UUID(session_id)) if not session: @@ -241,6 +260,7 @@ async def create_user( display_name: str | None = None, blocked: bool = False, ) -> User: +<<<<<<< HEAD try: hashed = hash_password(password) user = await self.user_querier.create_user( @@ -265,6 +285,28 @@ async def create_user( except Exception as exc: logger.error("Failed to create user: %s", exc) raise DBException.handle(exc) +======= + hashed = hash_password(password) + user = await self.user_querier.create_user( + email=email, + hashed_password=hashed, + ) + if not user: + raise AppException.internal_error("Failed to create user") + + if display_name is not None or blocked: + updated = await self.user_querier.update_user( + email=user.email, + display_name=display_name, + blocked=blocked, + id=user.id, + ) + if not updated: + raise AppException.internal_error("Failed to update user") + return updated + + return user +>>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) async def get_user(self, *, user_id: uuid.UUID) -> User: user = await self.user_querier.get_user_by_id(id=user_id) @@ -273,6 +315,7 @@ async def get_user(self, *, user_id: uuid.UUID) -> User: return user async def list_users(self, *, limit: int, offset: int) -> list[User]: +<<<<<<< HEAD try: users: list[User] = [] async for user in self.user_querier.list_users(limit=limit, offset=offset): @@ -281,6 +324,12 @@ async def list_users(self, *, limit: int, offset: int) -> list[User]: except Exception as exc: logger.error("Failed to list users: %s", exc) raise DBException.handle(exc) +======= + users: list[User] = [] + async for user in self.user_querier.list_users(limit=limit, offset=offset): + users.append(user) + return users +>>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) async def update_user( self, @@ -290,6 +339,7 @@ async def update_user( display_name: str | None = None, blocked: bool | None = None, ) -> User: +<<<<<<< HEAD try: existing = await self.user_querier.get_user_by_id(id=user_id) if not existing: @@ -354,3 +404,54 @@ async def unblock_user(self, *, user_id: uuid.UUID) -> User: except Exception as exc: logger.error("Failed to unblock user: %s", exc) raise DBException.handle(exc) +======= + existing = await self.user_querier.get_user_by_id(id=user_id) + if not existing: + raise AppException.not_found("User not found") + + new_email = email if email is not None else existing.email + new_display_name = ( + display_name if display_name is not None else existing.display_name + ) + new_blocked = blocked if blocked is not None else existing.blocked + + user = await self.user_querier.update_user( + email=new_email, + display_name=new_display_name, + blocked=new_blocked, + id=user_id, + ) + if not user: + raise AppException.internal_error("Failed to update user") + return user + + async def delete_user(self, *, user_id: uuid.UUID) -> User: + existing = await self.user_querier.get_user_by_id(id=user_id) + if not existing: + raise AppException.not_found("User not found") + await self.user_querier.delete_user(id=user_id) + return existing + + async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: + user = await self.user_querier.set_user_blocked(blocked=True, id=user_id) + if not user: + raise AppException.not_found("User not found") + + async for session in self.session_querier.list_sessions_by_user(user_id=user_id): + await blacklist_session( + redis=redis, + session_id=str(session.id), + expires_at=session.expires_at, + ) + + session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id) + await redis.delete(session_key) + + return user + + async def unblock_user(self, *, user_id: uuid.UUID) -> User: + user = await self.user_querier.set_user_blocked(blocked=False, id=user_id) + if not user: + raise AppException.not_found("User not found") + return user +>>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) From 4a9bf8a1d5056fd3719e7f804c5ed6a2b26c43d4 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 03:27:47 +0100 Subject: [PATCH 31/47] Add admin user CRUD and block/unblock endpoints --- app/router/web/users.py | 75 ++++++++++++++++++++++++++++++++- app/schema/response/web/user.py | 6 +++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/app/router/web/users.py b/app/router/web/users.py index 4866d5f..751631d 100644 --- a/app/router/web/users.py +++ b/app/router/web/users.py @@ -10,7 +10,6 @@ from app.schema.response.web.user import AdminUserSchema, to_admin_user_schema from db.generated.models import StaffUser - router = APIRouter(prefix="/users") @router.post("/", response_model=AdminUserSchema, status_code=status.HTTP_201_CREATED) @@ -28,18 +27,33 @@ async def create_user( logger.info("admin %s created user %s", current_staff_user.id, user.id) return to_admin_user_schema(user) - @router.get("/", response_model=list[AdminUserSchema]) async def list_users( limit: int = Query( settings.ADMIN_USERS_DEFAULT_LIMIT, ge=1, le=settings.ADMIN_USERS_MAX_LIMIT ), + limit: int = Query(20, ge=1, le=100), +>>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) offset: int = Query(0, ge=0), current_staff_user: StaffUser = Depends(get_current_staff_user), container: Container = Depends(get_container), ) -> list[AdminUserSchema]: users = await container.auth_service.list_users(limit=limit, offset=offset) +<<<<<<< HEAD return [to_admin_user_schema(user) for user in users] +======= + return [ + AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) + for user in users + ] +>>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) @router.get("/{user_id}", response_model=AdminUserSchema) @@ -49,7 +63,18 @@ async def get_user( container: Container = Depends(get_container), ) -> AdminUserSchema: user = await container.auth_service.get_user(user_id=user_id) +<<<<<<< HEAD return to_admin_user_schema(user) +======= + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) +>>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) @router.put("/{user_id}", response_model=AdminUserSchema) @@ -66,7 +91,18 @@ async def update_user( blocked=req.blocked, ) logger.info("admin %s updated user %s", current_staff_user.id, user_id) +<<<<<<< HEAD return to_admin_user_schema(user) +======= + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) +>>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) @router.delete("/{user_id}", response_model=AdminUserSchema) @@ -75,12 +111,25 @@ async def delete_user( current_staff_user: StaffUser = Depends(get_current_staff_user), container: Container = Depends(get_container), ) -> AdminUserSchema: +<<<<<<< HEAD user = await container.auth_service.delete_user( redis=container.redis, user_id=user_id, ) logger.info("admin %s deleted user %s", current_staff_user.id, user_id) return to_admin_user_schema(user) +======= + user = await container.auth_service.delete_user(user_id=user_id) + logger.info("admin %s deleted user %s", current_staff_user.id, user_id) + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) +>>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) @router.post("/{user_id}/block", response_model=AdminUserSchema) @@ -94,7 +143,18 @@ async def block_user( user_id=user_id, ) logger.info("admin %s blocked user %s", current_staff_user.id, user_id) +<<<<<<< HEAD return to_admin_user_schema(user) +======= + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) +>>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) @router.post("/{user_id}/unblock", response_model=AdminUserSchema) @@ -105,4 +165,15 @@ async def unblock_user( ) -> AdminUserSchema: user = await container.auth_service.unblock_user(user_id=user_id) logger.info("admin %s unblocked user %s", current_staff_user.id, user_id) +<<<<<<< HEAD return to_admin_user_schema(user) +======= + return AdminUserSchema( + id=user.id, + email=user.email, + display_name=user.display_name, + blocked=user.blocked, + created_at=user.created_at, + updated_at=user.updated_at, + ) +>>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) diff --git a/app/schema/response/web/user.py b/app/schema/response/web/user.py index bd79627..a3bc0ba 100644 --- a/app/schema/response/web/user.py +++ b/app/schema/response/web/user.py @@ -2,7 +2,10 @@ from uuid import UUID from pydantic import BaseModel +<<<<<<< HEAD from db.generated.models import User +======= +>>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) class AdminUserSchema(BaseModel): @@ -12,6 +15,7 @@ class AdminUserSchema(BaseModel): blocked: bool created_at: datetime updated_at: datetime +<<<<<<< HEAD def to_admin_user_schema(user: User) -> AdminUserSchema: @@ -23,3 +27,5 @@ def to_admin_user_schema(user: User) -> AdminUserSchema: created_at=user.created_at, updated_at=user.updated_at, ) +======= +>>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) From 6fb1c15582fd394a3c25a12078990a64d216b1dd Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 10:18:09 +0100 Subject: [PATCH 32/47] Use settings and consistent DB error handling in user service --- app/service/users.py | 195 ++++++++++++++++++++----------------------- 1 file changed, 90 insertions(+), 105 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index 4eb54a2..9c07019 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -13,7 +13,7 @@ Get_expiry_time, ) from app.core.config import settings -from app.core.token_blacklist import blacklist_session, is_session_blacklisted +from app.infra.redis import RedisClient from app.infra.redis import RedisClient from app.schema.request.mobile.auth import MobileAuthRequest @@ -159,9 +159,6 @@ async def refresh_token( if not session_id: raise AppException.unauthorized("Invalid refresh token") - if await is_session_blacklisted(redis, session_id): - raise AppException.unauthorized("Token is blacklisted") - session = await self.session_querier.get_session_by_id(id=uuid.UUID(session_id)) if not session: @@ -170,27 +167,11 @@ async def refresh_token( if session.expires_at < datetime.now(timezone.utc): raise AppException.unauthorized("Session expired") -<<<<<<< HEAD -======= - session_key = constant.RedisKey.UserSessionByUser.value.format( - user_id=session.user_id - ) - redis_session = await redis.get(session_key) - - if not redis_session or redis_session != session_id: - raise AppException.unauthorized("Session invalidated") - ->>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) user = await self.user_querier.get_user_by_id(id=session.user_id) if not user: raise AppException.unauthorized("User not found") if user.blocked: raise AppException.forbidden("User is blocked") -<<<<<<< HEAD -======= - - await redis.expire(session_key, AuthService.REDIS_SESSION_TTL) ->>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) new_access_token = create_acces_mobile_token(session_id) new_refresh_token = create_refresh_mobile_token(session_id) @@ -203,52 +184,6 @@ async def refresh_token( expires_in=expiry, ) - async def refresh_token( - self, - refresh_token: str, - ) -> MobileAuthResponse: - payload = decode_refresh_mobile_token(refresh_token) - session_id = payload.get("session_id") - - if not session_id: - raise AppException.unauthorized("Invalid refresh token") - - session = await self.session_querier.get_session_by_id(id=uuid.UUID(session_id)) - - if not session: - raise AppException.unauthorized("Session not found") - - if session.expires_at < datetime.now(timezone.utc): - raise AppException.unauthorized("Session expired") - - user = await self.user_querier.get_user_by_id(id=session.user_id) - if not user: - raise AppException.unauthorized("User not found") - if user.blocked: - raise AppException.forbidden("User is blocked") - - new_access_token = create_acces_mobile_token(session_id) - new_refresh_token = create_refresh_mobile_token(session_id) - expiry = Get_expiry_time() - - return MobileAuthResponse( - access_token=new_access_token, - refresh_token=new_refresh_token, - session_id=session_id, - expires_in=expiry, - ) - if await is_session_blacklisted(redis, session_id): - return False - - session = await self.session_querier.get_session_by_id(id=uuid.UUID(session_id)) - - if not session: - return False - - if session.expires_at < datetime.now(timezone.utc): - return False - return True - async def get_user_by_id(self, user_id: uuid.UUID) -> User | None: return await self.user_querier.get_user_by_id(id=user_id) @@ -261,11 +196,15 @@ async def create_user( blocked: bool = False, ) -> User: <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) try: hashed = hash_password(password) user = await self.user_querier.create_user( email=email, hashed_password=hashed, +<<<<<<< HEAD ) if not user: raise AppException.internal_error("Failed to create user") @@ -300,13 +239,34 @@ async def create_user( display_name=display_name, blocked=blocked, id=user.id, +======= +>>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) ) - if not updated: - raise AppException.internal_error("Failed to update user") - return updated + if not user: + raise AppException.internal_error("Failed to create user") +<<<<<<< HEAD return user >>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) +======= + if display_name is not None or blocked: + updated = await self.user_querier.update_user( + email=user.email, + display_name=display_name, + blocked=blocked, + id=user.id, + ) + if not updated: + raise AppException.internal_error("Failed to update user") + return updated + + return user + except AppException: + raise + except Exception as exc: + logger.error("Failed to create user: %s", exc) + raise DBException.handle(exc) +>>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) async def get_user(self, *, user_id: uuid.UUID) -> User: user = await self.user_querier.get_user_by_id(id=user_id) @@ -316,6 +276,9 @@ async def get_user(self, *, user_id: uuid.UUID) -> User: async def list_users(self, *, limit: int, offset: int) -> list[User]: <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) try: users: list[User] = [] async for user in self.user_querier.list_users(limit=limit, offset=offset): @@ -324,12 +287,15 @@ async def list_users(self, *, limit: int, offset: int) -> list[User]: except Exception as exc: logger.error("Failed to list users: %s", exc) raise DBException.handle(exc) +<<<<<<< HEAD ======= users: list[User] = [] async for user in self.user_querier.list_users(limit=limit, offset=offset): users.append(user) return users >>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) +======= +>>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) async def update_user( self, @@ -340,10 +306,14 @@ async def update_user( blocked: bool | None = None, ) -> User: <<<<<<< HEAD +<<<<<<< HEAD +======= +>>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) try: existing = await self.user_querier.get_user_by_id(id=user_id) if not existing: raise AppException.not_found("User not found") +<<<<<<< HEAD new_email = email if email is not None else existing.email new_display_name = ( @@ -408,50 +378,65 @@ async def unblock_user(self, *, user_id: uuid.UUID) -> User: existing = await self.user_querier.get_user_by_id(id=user_id) if not existing: raise AppException.not_found("User not found") +======= +>>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) - new_email = email if email is not None else existing.email - new_display_name = ( - display_name if display_name is not None else existing.display_name - ) - new_blocked = blocked if blocked is not None else existing.blocked + new_email = email if email is not None else existing.email + new_display_name = ( + display_name if display_name is not None else existing.display_name + ) + new_blocked = blocked if blocked is not None else existing.blocked - user = await self.user_querier.update_user( - email=new_email, - display_name=new_display_name, - blocked=new_blocked, - id=user_id, - ) - if not user: - raise AppException.internal_error("Failed to update user") - return user + user = await self.user_querier.update_user( + email=new_email, + display_name=new_display_name, + blocked=new_blocked, + id=user_id, + ) + if not user: + raise AppException.internal_error("Failed to update user") + return user + except AppException: + raise + except Exception as exc: + logger.error("Failed to update user: %s", exc) + raise DBException.handle(exc) async def delete_user(self, *, user_id: uuid.UUID) -> User: - existing = await self.user_querier.get_user_by_id(id=user_id) - if not existing: - raise AppException.not_found("User not found") - await self.user_querier.delete_user(id=user_id) - return existing + try: + existing = await self.user_querier.get_user_by_id(id=user_id) + if not existing: + raise AppException.not_found("User not found") + await self.user_querier.delete_user(id=user_id) + return existing + except AppException: + raise + except Exception as exc: + logger.error("Failed to delete user: %s", exc) + raise DBException.handle(exc) async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: - user = await self.user_querier.set_user_blocked(blocked=True, id=user_id) - if not user: - raise AppException.not_found("User not found") + try: + user = await self.user_querier.set_user_blocked(blocked=True, id=user_id) + if not user: + raise AppException.not_found("User not found") - async for session in self.session_querier.list_sessions_by_user(user_id=user_id): - await blacklist_session( - redis=redis, - session_id=str(session.id), - expires_at=session.expires_at, + session_key = constant.RedisKey.UserSessionByUser.value.format( + user_id=user_id ) + await redis.delete(session_key) - session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id) - await redis.delete(session_key) - - return user + return user + except Exception as exc: + logger.error("Failed to block user: %s", exc) + raise DBException.handle(exc) async def unblock_user(self, *, user_id: uuid.UUID) -> User: - user = await self.user_querier.set_user_blocked(blocked=False, id=user_id) - if not user: - raise AppException.not_found("User not found") - return user ->>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) + try: + user = await self.user_querier.set_user_blocked(blocked=False, id=user_id) + if not user: + raise AppException.not_found("User not found") + return user + except Exception as exc: + logger.error("Failed to unblock user: %s", exc) + raise DBException.handle(exc) From 72cade87d473634a092f27b217b1c4d535e30226 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:32:07 +0100 Subject: [PATCH 33/47] chore: remove token blacklist helpers --- app/core/constant.py | 5 ----- app/core/token_blacklist.py | 23 ----------------------- 2 files changed, 28 deletions(-) delete mode 100644 app/core/token_blacklist.py diff --git a/app/core/constant.py b/app/core/constant.py index 27c2b05..0a217dd 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -4,7 +4,6 @@ class RedisKey(str, Enum): UserSession = "user_session" UserSessionByUser = "user_session:{user_id}" -<<<<<<< HEAD INVALID_TOKEN_SET_KEY= "notifications:invalid_tokens" @@ -24,10 +23,6 @@ class AuditEventType(str, Enum): UPLOAD_REQUEST_REJECTED = "upload_request.rejected" -======= ->>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) - BlacklistedSession = "blacklist:session:{session_id}" - IMAGE_ALLOWED_TYPES = { "image/jpeg", "image/png", diff --git a/app/core/token_blacklist.py b/app/core/token_blacklist.py deleted file mode 100644 index 3f22038..0000000 --- a/app/core/token_blacklist.py +++ /dev/null @@ -1,23 +0,0 @@ -from datetime import datetime, timezone - -from app.core.constant import RedisKey -from app.infra.redis import RedisClient - - -async def blacklist_session( - redis: RedisClient, - session_id: str, - expires_at: datetime | None = None, -) -> None: - ttl: int | None = None - if expires_at is not None: - ttl = int((expires_at - datetime.now(timezone.utc)).total_seconds()) - if ttl < 0: - ttl = 0 - key = RedisKey.BlacklistedSession.value.format(session_id=session_id) - await redis.set(key, "1", expire=ttl) - - -async def is_session_blacklisted(redis: RedisClient, session_id: str) -> bool: - key = RedisKey.BlacklistedSession.value.format(session_id=session_id) - return await redis.exists(key) From f81bcf387b0225cfce297af0edc2a0624c00962f Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 02:35:14 +0100 Subject: [PATCH 34/47] chore: remove legacy batch face worker --- app/container.py | 12 - app/core/config.py | 4 +- app/infra/nats.py | 2 +- app/router/staff/__init__.py | 2 - app/router/staff/face_embeddings.py | 31 -- app/schema/dto/face_embeddings.py | 32 -- app/schema/request/staff/face_embeddings.py | 16 - app/schema/response/staff/face_embeddings.py | 10 - app/service/batch_face_embedding.py | 340 ------------------- app/service/batch_face_embedding_queue.py | 43 --- app/worker/batch_face_embeddings.py | 55 --- 11 files changed, 3 insertions(+), 544 deletions(-) delete mode 100644 app/router/staff/face_embeddings.py delete mode 100644 app/schema/dto/face_embeddings.py delete mode 100644 app/schema/request/staff/face_embeddings.py delete mode 100644 app/schema/response/staff/face_embeddings.py delete mode 100644 app/service/batch_face_embedding.py delete mode 100644 app/service/batch_face_embedding_queue.py delete mode 100644 app/worker/batch_face_embeddings.py diff --git a/app/container.py b/app/container.py index 35adfb7..be30b37 100644 --- a/app/container.py +++ b/app/container.py @@ -5,8 +5,6 @@ from app.infra.database import get_db from app.infra.redis import RedisClient from app.service.device import DeviceService -from app.service.batch_face_embedding import BatchFaceEmbeddingService -from app.service.batch_face_embedding_queue import BatchFaceEmbeddingQueueService from app.service.face_embedding import FaceEmbeddingService from app.service.session import SessionService from app.service.staged_upload_storage import StagedUploadStorageService @@ -106,7 +104,6 @@ def __init__( staff_notifications_service=self.staff_notifications_service, ) -<<<<<<< HEAD notification_queue = NotificationQueue(settings=NotifSetting) self.user_notifications_service = UserNotificationService( @@ -117,18 +114,9 @@ def __init__( self.audit_service = AuditService( audit_querier=self.audit_querier, user_querier=self.user_querier, -======= - self.batch_face_embedding_service = BatchFaceEmbeddingService( - face_embedding_service=self.face_embedding_service, - staff_drive_service=self.staff_drive_service, - photo_face_querier=self.photo_face_querier, ->>>>>>> 08a1d9f (feat: wire batch face embedding service in container) ) - self.batch_face_embedding_queue_service = BatchFaceEmbeddingQueueService() - self.staff_user_service = StaffUserService() - self.staff_user_service.init( staff_user_querier=self.staff_user_querier,) diff --git a/app/core/config.py b/app/core/config.py index 248e7f0..c767238 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -16,8 +16,8 @@ class Settings(BaseSettings): NATS_HOST: str NATS_PASSWORD: str NATS_USER: str - NATS_FACE_EMBEDDING_STREAM: str = "face_embeddings" - NATS_FACE_EMBEDDING_DURABLE: str = "face_embeddings_worker" + NATS_SINGLE_FACE_MATCH_STREAM: str = "single_face_matches" + NATS_SINGLE_FACE_MATCH_DURABLE: str = "single_face_match_worker" # MinIO diff --git a/app/infra/nats.py b/app/infra/nats.py index 784a2ef..5f01b55 100644 --- a/app/infra/nats.py +++ b/app/infra/nats.py @@ -29,7 +29,7 @@ class NatsSubjects(Enum): STAFF_UPLOAD_REQUEST_CREATED = "staff.upload_request.created" STAFF_UPLOAD_REQUEST_APPROVED = "staff.upload_request.approved" STAFF_UPLOAD_REQUEST_REJECTED = "staff.upload_request.rejected" - BATCH_FACE_EMBEDDINGS_REQUESTED = "photo_faces.batch.requested" + SINGLE_FACE_MATCH_REQUESTED = "photo_faces.single.requested" class NatsClient: _nc: Optional[NATS] = None diff --git a/app/router/staff/__init__.py b/app/router/staff/__init__.py index 36c4013..e17b02c 100644 --- a/app/router/staff/__init__.py +++ b/app/router/staff/__init__.py @@ -1,12 +1,10 @@ from fastapi import APIRouter from app.router.staff.drive import router as staff_drive_router -from app.router.staff.face_embeddings import router as staff_face_embeddings_router from app.router.staff.notifications import router as staff_notifications_router from app.router.staff.uploads import router as staff_uploads_router router = APIRouter(prefix="/stuff", tags=["stuff"]) router.include_router(staff_drive_router) -router.include_router(staff_face_embeddings_router) router.include_router(staff_notifications_router) router.include_router(staff_uploads_router) diff --git a/app/router/staff/face_embeddings.py b/app/router/staff/face_embeddings.py deleted file mode 100644 index 6387d46..0000000 --- a/app/router/staff/face_embeddings.py +++ /dev/null @@ -1,31 +0,0 @@ -from fastapi import APIRouter, Depends - -from app.container import Container, get_container -from app.deps.cookie_auth import get_current_staff_user -from app.schema.request.staff.face_embeddings import BatchFaceEmbeddingsRequest -from app.schema.response.staff.face_embeddings import BatchFaceEmbeddingEnqueueResponse -from db.generated.models import StaffUser - - -router = APIRouter(prefix="/batch") - - -@router.post( - "/face-embeddings", - response_model=BatchFaceEmbeddingEnqueueResponse, - status_code=202, -) -async def batch_face_embeddings( - req: BatchFaceEmbeddingsRequest, - current_staff_user: StaffUser = Depends(get_current_staff_user), - container: Container = Depends(get_container), -) -> BatchFaceEmbeddingEnqueueResponse: - job = await container.batch_face_embedding_queue_service.enqueue( - items=req.to_inputs(), - staff_user_id=current_staff_user.id, - ) - return BatchFaceEmbeddingEnqueueResponse( - job_id=job.job_id, - queued=len(job.items), - submitted_at=job.submitted_at, - ) diff --git a/app/schema/dto/face_embeddings.py b/app/schema/dto/face_embeddings.py deleted file mode 100644 index 4be79f9..0000000 --- a/app/schema/dto/face_embeddings.py +++ /dev/null @@ -1,32 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone -from typing import Literal -from uuid import UUID, uuid4 - -from pydantic import BaseModel, Field - -from app.service.batch_face_embedding import BatchImageInput - - -class BatchFaceEmbeddingItem(BaseModel): - photo_id: UUID - source_type: Literal["drive", "minio", "local"] - source: str = Field(min_length=1, max_length=2048) - - def to_input(self) -> BatchImageInput: - return BatchImageInput( - photo_id=self.photo_id, - source_type=self.source_type, - source=self.source, - ) - - -class BatchFaceEmbeddingJob(BaseModel): - job_id: UUID = Field(default_factory=uuid4) - staff_user_id: UUID - items: list[BatchFaceEmbeddingItem] - submitted_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - - def to_inputs(self) -> list[BatchImageInput]: - return [item.to_input() for item in self.items] diff --git a/app/schema/request/staff/face_embeddings.py b/app/schema/request/staff/face_embeddings.py deleted file mode 100644 index cd2a0db..0000000 --- a/app/schema/request/staff/face_embeddings.py +++ /dev/null @@ -1,16 +0,0 @@ -from pydantic import BaseModel, Field - -from app.schema.dto.face_embeddings import BatchFaceEmbeddingItem - - -MAX_BATCH_SIZE = 200 - - -class BatchFaceEmbeddingsRequest(BaseModel): - items: list[BatchFaceEmbeddingItem] = Field( - min_length=1, - max_length=MAX_BATCH_SIZE, - ) - - def to_inputs(self) -> list[BatchFaceEmbeddingItem]: - return self.items diff --git a/app/schema/response/staff/face_embeddings.py b/app/schema/response/staff/face_embeddings.py deleted file mode 100644 index 5946b0a..0000000 --- a/app/schema/response/staff/face_embeddings.py +++ /dev/null @@ -1,10 +0,0 @@ -from datetime import datetime -from uuid import UUID - -from pydantic import BaseModel - - -class BatchFaceEmbeddingEnqueueResponse(BaseModel): - job_id: UUID - queued: int - submitted_at: datetime diff --git a/app/service/batch_face_embedding.py b/app/service/batch_face_embedding.py deleted file mode 100644 index 9e0c615..0000000 --- a/app/service/batch_face_embedding.py +++ /dev/null @@ -1,340 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from dataclasses import dataclass -import json -import mimetypes -from pathlib import Path -import uuid -from typing import Literal -from urllib.parse import urlparse, parse_qs - -from sqlalchemy.exc import IntegrityError - -from app.core.exceptions import AppException -from app.core.logger import logger -from app.core.constant import DEFAULT_CONTENT_TYPE, DRIVE_ALLOWED_HOSTS, MINIO_URL_PREFIX -from app.infra.google_drive import GoogleDriveClient, GoogleDriveFileDownload -from app.infra.minio import Bucket, IMAGES_BUCKET_NAME -from app.service.face_embedding import DetectedFace, FaceEmbeddingService, FaceImagePayload -from app.service.staff_drive import StaffDriveService -from db.generated import models -from db.generated import photo_faces as photo_face_queries - - -SourceType = Literal["drive", "minio", "local"] - - -@dataclass(frozen=True) -class BatchImageInput: - photo_id: uuid.UUID - source_type: SourceType - source: str - - -@dataclass(frozen=True) -class BatchImageResult: - photo_id: uuid.UUID - source_type: SourceType - source: str - faces_detected: int - faces_stored: int - errors: list[str] - - -@dataclass(frozen=True) -class BatchFaceEmbeddingSummary: - total_images: int - total_faces_detected: int - total_faces_stored: int - failures: int - results: list[BatchImageResult] - - -class BatchFaceEmbeddingService: - def __init__( - self, - face_embedding_service: FaceEmbeddingService, - staff_drive_service: StaffDriveService, - photo_face_querier: photo_face_queries.AsyncQuerier, - ) -> None: - self.face_embedding_service = face_embedding_service - self.staff_drive_service = staff_drive_service - self.photo_face_querier = photo_face_querier - self.default_bucket = Bucket(IMAGES_BUCKET_NAME, "") - - async def process_batch( - self, - *, - items: Sequence[BatchImageInput], - staff_user_id: uuid.UUID | None, - ) -> BatchFaceEmbeddingSummary: - if not items: - raise AppException.bad_request("At least one image is required") - - access_token = await self._get_access_token(items, staff_user_id) - results: list[BatchImageResult] = [] - total_faces_detected = 0 - total_faces_stored = 0 - - for item in items: - result, faces_detected, faces_stored = await self._process_item( - item, - access_token, - ) - total_faces_detected += faces_detected - total_faces_stored += faces_stored - results.append(result) - - failures = sum(1 for result in results if result.errors) - return BatchFaceEmbeddingSummary( - total_images=len(items), - total_faces_detected=total_faces_detected, - total_faces_stored=total_faces_stored, - failures=failures, - results=results, - ) - - async def _get_access_token( - self, - items: Sequence[BatchImageInput], - staff_user_id: uuid.UUID | None, - ) -> str | None: - if not any(item.source_type == "drive" for item in items): - return None - if staff_user_id is None: - raise AppException.bad_request("staff_user_id is required for drive sources") - return await self.staff_drive_service.get_access_token_for_staff_user( - staff_user_id - ) - - async def _process_item( - self, - item: BatchImageInput, - access_token: str | None, - ) -> tuple[BatchImageResult, int, int]: - errors: list[str] = [] - try: - payload = await self._load_payload(item, access_token) - except Exception as exc: - logger.warning( - "Failed to load image for photo %s (%s): %s", - item.photo_id, - item.source, - exc, - ) - errors.append(str(exc)) - return self._build_result(item, 0, 0, errors), 0, 0 - - try: - faces = await self.face_embedding_service.detect_faces(payload) - except Exception as exc: - logger.warning( - "Face detection failed for photo %s: %s", - item.photo_id, - exc, - ) - errors.append(str(exc)) - return self._build_result(item, 0, 0, errors), 0, 0 - - faces_detected = len(faces) - if not faces: - errors.append("No faces detected") - - faces_stored = await self._store_faces(item.photo_id, faces, errors) - return ( - self._build_result(item, faces_detected, faces_stored, errors), - faces_detected, - faces_stored, - ) - - async def _store_faces( - self, - photo_id: uuid.UUID, - faces: Sequence[DetectedFace], - errors: list[str], - ) -> int: - faces_stored = 0 - for face_index, face in enumerate(faces): - try: - stored = await self._store_face( - photo_id=photo_id, - face_index=face_index, - face=face, - ) - if stored is None: - raise AppException.internal_error("Failed to store face embedding") - await self._commit_best_effort() - faces_stored += 1 - except IntegrityError as exc: - await self._rollback_best_effort() - logger.warning( - "Failed to store face %s for photo %s: %s", - face_index, - photo_id, - exc, - ) - errors.append(f"face {face_index}: {exc}") - except Exception as exc: - await self._rollback_best_effort() - logger.warning( - "Failed to store face %s for photo %s: %s", - face_index, - photo_id, - exc, - ) - errors.append(f"face {face_index}: {exc}") - return faces_stored - - @staticmethod - def _build_result( - item: BatchImageInput, - faces_detected: int, - faces_stored: int, - errors: list[str], - ) -> BatchImageResult: - return BatchImageResult( - photo_id=item.photo_id, - source_type=item.source_type, - source=item.source, - faces_detected=faces_detected, - faces_stored=faces_stored, - errors=errors, - ) - - async def _load_payload( - self, - item: BatchImageInput, - access_token: str | None, - ) -> FaceImagePayload: - if item.source_type == "drive": - if access_token is None: - raise AppException.bad_request("Missing Google Drive access token") - return await self._load_from_drive(item.source, access_token) - if item.source_type == "minio": - return await self._load_from_minio(item.source) - if item.source_type == "local": - return self._load_from_local(item.source) - raise AppException.bad_request("Unsupported source type") - - async def _load_from_drive( - self, - source: str, - access_token: str, - ) -> FaceImagePayload: - file_id = self._extract_drive_file_id(source) - downloaded = await GoogleDriveClient.download_file( - access_token=access_token, - file_id=file_id, - ) - return self._payload_from_drive(downloaded) - - async def _load_from_minio(self, source: str) -> FaceImagePayload: - bucket_name, object_name = self._parse_minio_source(source) - bucket = self.default_bucket - if bucket_name != IMAGES_BUCKET_NAME: - bucket = Bucket(bucket_name, "") - data, filename, content_type = await bucket.get(object_name) - return FaceImagePayload( - filename=filename, - content_type=content_type, - bytes=data, - ) - - def _load_from_local(self, source: str) -> FaceImagePayload: - path = self._normalize_local_path(source) - if not path.exists() or not path.is_file(): - raise AppException.not_found(f"Local file not found: {path}") - data = path.read_bytes() - content_type = mimetypes.guess_type(path.name)[0] or DEFAULT_CONTENT_TYPE - return FaceImagePayload( - filename=path.name, - content_type=content_type, - bytes=data, - ) - - async def _store_face( - self, - *, - photo_id: uuid.UUID, - face_index: int, - face: DetectedFace, - ) -> models.PhotoFace | None: - bbox_payload = { - "x1": float(face.bbox[0]), - "y1": float(face.bbox[1]), - "x2": float(face.bbox[2]), - "y2": float(face.bbox[3]), - } - embedding_literal = self._vector_literal(face.embedding) - return await self.photo_face_querier.upsert_photo_face( - photo_id=photo_id, - face_index=face_index, - dollar_3=embedding_literal, - bbox=json.dumps(bbox_payload), - ) - - async def _rollback_best_effort(self) -> None: - conn = getattr(self.photo_face_querier, "_conn", None) - if conn is None: - return - try: - await conn.rollback() - except Exception: - pass - - async def _commit_best_effort(self) -> None: - conn = getattr(self.photo_face_querier, "_conn", None) - if conn is None: - return - try: - await conn.commit() - except Exception: - pass - - @staticmethod - def _vector_literal(embedding: Sequence[float]) -> str: - return "[" + ", ".join(str(x) for x in embedding) + "]" - - @staticmethod - def _payload_from_drive(downloaded: GoogleDriveFileDownload) -> FaceImagePayload: - metadata = downloaded.metadata - return FaceImagePayload( - filename=metadata.name, - content_type=metadata.mime_type, - bytes=downloaded.content, - ) - - @staticmethod - def _extract_drive_file_id(source: str) -> str: - if source.startswith("http://") or source.startswith("https://"): - parsed = urlparse(source) - if not any(host in parsed.netloc for host in DRIVE_ALLOWED_HOSTS): - raise AppException.bad_request("Invalid Google Drive URL") - query_id = parse_qs(parsed.query).get("id") - if query_id and query_id[0]: - return query_id[0] - parts = [part for part in parsed.path.split("/") if part] - if "d" in parts: - d_index = parts.index("d") - if d_index + 1 < len(parts): - return parts[d_index + 1] - raise AppException.bad_request("Google Drive file id not found in URL") - return source - - @staticmethod - def _parse_minio_source(source: str) -> tuple[str, str]: - if source.startswith(MINIO_URL_PREFIX): - raw = source[len(MINIO_URL_PREFIX) :] - parts = raw.split("/", 1) - if len(parts) != 2 or not parts[0] or not parts[1]: - raise AppException.bad_request("Invalid MinIO source format") - return parts[0], parts[1] - return IMAGES_BUCKET_NAME, source - - @staticmethod - def _normalize_local_path(source: str) -> Path: - normalized = source - if source.startswith("file://"): - normalized = source[len("file://") :] - return Path(normalized) diff --git a/app/service/batch_face_embedding_queue.py b/app/service/batch_face_embedding_queue.py deleted file mode 100644 index f72be5c..0000000 --- a/app/service/batch_face_embedding_queue.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -import json -from uuid import UUID - -from app.core.config import settings -from app.core.exceptions import AppException -from app.core.logger import logger -from app.infra.nats import NatsClient, NatsSubjects -from app.schema.dto.face_embeddings import ( - BatchFaceEmbeddingItem, - BatchFaceEmbeddingJob, -) - - -class BatchFaceEmbeddingQueueService: - def __init__(self) -> None: - self.stream_name = settings.NATS_FACE_EMBEDDING_STREAM - - async def enqueue( - self, - *, - items: Sequence[BatchFaceEmbeddingItem], - staff_user_id: UUID, - ) -> BatchFaceEmbeddingJob: - job = BatchFaceEmbeddingJob( - staff_user_id=staff_user_id, - items=list(items), - ) - - payload = job.model_dump(mode="json") - try: - await NatsClient.js_publish( - subject=NatsSubjects.BATCH_FACE_EMBEDDINGS_REQUESTED, - message=json.dumps(payload).encode("utf-8"), - stream_name=self.stream_name, - ) - except Exception as exc: - logger.warning("Failed to enqueue batch face embedding job: %s", exc) - raise AppException.internal_error("Failed to enqueue batch face embedding job") from exc - - return job diff --git a/app/worker/batch_face_embeddings.py b/app/worker/batch_face_embeddings.py deleted file mode 100644 index 83325bd..0000000 --- a/app/worker/batch_face_embeddings.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -import asyncio - -from app.container import Container -from app.core.config import settings -from app.core.logger import logger -from app.infra.database import engine -from app.infra.minio import init_minio_client -from app.infra.nats import NatsClient, NatsSubjects -from app.infra.redis import RedisClient -from app.schema.dto.face_embeddings import BatchFaceEmbeddingJob - - -class BatchFaceEmbeddingWorker: - def __init__(self, container: Container) -> None: - self.container = container - - async def handle_message(self, data: bytes) -> None: - job = BatchFaceEmbeddingJob.model_validate_json(data) - await self.container.batch_face_embedding_service.process_batch( - items=job.to_inputs(), - staff_user_id=job.staff_user_id, - ) - - -async def run_worker() -> None: - await init_minio_client( - minio_host=settings.MINIO_HOST, - minio_port=settings.MINIO_API_PORT, - minio_root_user=settings.MINIO_ROOT_USER, - minio_root_password=settings.MINIO_ROOT_PASSWORD, - ) - RedisClient( - host=settings.REDIS_HOST, - port=settings.REDIS_PORT, - password=settings.REDIS_PASSWORD, - ) - async with engine.connect() as conn: - container = Container(conn) - worker = BatchFaceEmbeddingWorker(container) - - await NatsClient.js_subscribe( - subject=NatsSubjects.BATCH_FACE_EMBEDDINGS_REQUESTED, - callback=worker.handle_message, - stream_name=settings.NATS_FACE_EMBEDDING_STREAM, - durable_name=settings.NATS_FACE_EMBEDDING_DURABLE, - ) - - logger.info("BatchFaceEmbeddingWorker subscribed; waiting for jobs") - await asyncio.Event().wait() - - -if __name__ == "__main__": - asyncio.run(run_worker()) From 4b05dc1f12158b60161dcfc8d1de5093bded6cc5 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 02:35:20 +0100 Subject: [PATCH 35/47] feat: add single-face match worker --- app/schema/dto/single_face_match.py | 25 ++++ app/service/single_face_match.py | 202 ++++++++++++++++++++++++++++ app/worker/single_face_match.py | 61 +++++++++ 3 files changed, 288 insertions(+) create mode 100644 app/schema/dto/single_face_match.py create mode 100644 app/service/single_face_match.py create mode 100644 app/worker/single_face_match.py diff --git a/app/schema/dto/single_face_match.py b/app/schema/dto/single_face_match.py new file mode 100644 index 0000000..e691808 --- /dev/null +++ b/app/schema/dto/single_face_match.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from pydantic import BaseModel, Field + + +class BBoxPayload(BaseModel): + x1: float + y1: float + x2: float + y2: float + + +class SingleFaceMatchJob(BaseModel): + job_id: UUID = Field(default_factory=uuid4) + photo_id: UUID + face_index: int = 0 + image_ref: str + bbox: BBoxPayload | None = None + faces_detected: int | None = None + submitted_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + model_config = {"extra": "allow"} diff --git a/app/service/single_face_match.py b/app/service/single_face_match.py new file mode 100644 index 0000000..6b76950 --- /dev/null +++ b/app/service/single_face_match.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from uuid import UUID + +import sqlalchemy +import sqlalchemy.ext.asyncio + +from app.core.logger import logger +from app.service.face_embedding import FaceEmbeddingService, FaceImagePayload +from app.schema.dto.single_face_match import BBoxPayload, SingleFaceMatchJob +from db.generated import photo_faces as photo_face_queries +from db.generated import models + + +@dataclass(frozen=True) +class ClosestUserMatch: + user_id: UUID + distance: float + + +PHOTO_EXISTS = """ +SELECT 1 +FROM photos +WHERE id = :photo_id +""" + +GET_CLOSEST_USER = """ +SELECT id, (face_embedding <=> :embedding::vector) AS distance +FROM users +WHERE face_embedding IS NOT NULL +ORDER BY distance ASC +LIMIT 1 +""" + +INSERT_FACE_MATCH = """ +INSERT INTO face_matches (photo_face_id, user_id, confidence) +VALUES (:photo_face_id, :user_id, :confidence) +RETURNING id +""" + + +class SingleFaceMatchService: + def __init__( + self, + *, + conn: sqlalchemy.ext.asyncio.AsyncConnection, + face_embedding_service: FaceEmbeddingService, + photo_face_querier: photo_face_queries.AsyncQuerier, + ) -> None: + self.conn = conn + self.face_embedding_service = face_embedding_service + self.photo_face_querier = photo_face_querier + + async def process_job(self, job: SingleFaceMatchJob) -> None: + if job.faces_detected is not None and job.faces_detected != 1: + logger.info( + "Skipping photo %s: faces_detected=%s (single-face worker)", + job.photo_id, + job.faces_detected, + ) + return + + if not await self._photo_exists(job.photo_id): + logger.warning("Photo not found: %s", job.photo_id) + return + + embedding, bbox = await self._resolve_embedding(job) + if embedding is None: + return + + photo_face = await self._upsert_photo_face( + photo_id=job.photo_id, + face_index=job.face_index, + embedding=embedding, + bbox=bbox, + ) + if photo_face is None: + logger.warning("Failed to upsert photo_face for photo %s", job.photo_id) + return + await self._commit_best_effort() + + match = await self._find_closest_user(embedding) + if match is None: + logger.info("No user embeddings available for matching") + return + + await self._insert_face_match( + photo_face_id=photo_face.id, + user_id=match.user_id, + confidence=match.distance, + ) + await self._commit_best_effort() + + async def _photo_exists(self, photo_id: UUID) -> bool: + row = (await self.conn.execute( + sqlalchemy.text(PHOTO_EXISTS), + {"photo_id": photo_id}, + )).first() + return row is not None + + async def _resolve_embedding( + self, + job: SingleFaceMatchJob, + ) -> tuple[list[float] | None, BBoxPayload | None]: + try: + payload = self._load_payload(job) + except Exception as exc: + logger.warning("Failed to load image payload for photo %s: %s", job.photo_id, exc) + return None, None + + try: + faces = await self.face_embedding_service.detect_faces(payload) + except Exception as exc: + logger.warning("Face detection failed for photo %s: %s", job.photo_id, exc) + return None, None + + if len(faces) != 1: + logger.info( + "Skipping photo %s: detected %s faces (single-face worker)", + job.photo_id, + len(faces), + ) + return None, None + + face = faces[0] + bbox = BBoxPayload( + x1=float(face.bbox[0]), + y1=float(face.bbox[1]), + x2=float(face.bbox[2]), + y2=float(face.bbox[3]), + ) + return face.embedding, bbox + + def _load_payload(self, job: SingleFaceMatchJob) -> FaceImagePayload: + if job.image_ref: + raise NotImplementedError( + "MinIO image loading not implemented. TODO: fetch bytes from image_ref." + ) + + raise ValueError("Missing image_ref in event payload") + + async def _upsert_photo_face( + self, + *, + photo_id: UUID, + face_index: int, + embedding: list[float], + bbox: BBoxPayload | None, + ) -> models.PhotoFace | None: + embedding_literal = self._vector_literal(embedding) + bbox_payload = None + if bbox is not None: + bbox_payload = json.dumps( + {"x1": bbox.x1, "y1": bbox.y1, "x2": bbox.x2, "y2": bbox.y2} + ) + return await self.photo_face_querier.upsert_photo_face( + photo_id=photo_id, + face_index=face_index, + dollar_3=embedding_literal, + bbox=bbox_payload, + ) + + async def _find_closest_user( + self, + embedding: list[float], + ) -> ClosestUserMatch | None: + embedding_literal = self._vector_literal(embedding) + row = (await self.conn.execute( + sqlalchemy.text(GET_CLOSEST_USER), + {"embedding": embedding_literal}, + )).first() + if row is None: + return None + return ClosestUserMatch(user_id=row[0], distance=float(row[1])) + + async def _insert_face_match( + self, + *, + photo_face_id: UUID, + user_id: UUID, + confidence: float, + ) -> None: + await self.conn.execute( + sqlalchemy.text(INSERT_FACE_MATCH), + { + "photo_face_id": photo_face_id, + "user_id": user_id, + "confidence": confidence, + }, + ) + + async def _commit_best_effort(self) -> None: + try: + await self.conn.commit() + except Exception: + pass + + @staticmethod + def _vector_literal(embedding: list[float]) -> str: + return "[" + ", ".join(str(x) for x in embedding) + "]" diff --git a/app/worker/single_face_match.py b/app/worker/single_face_match.py new file mode 100644 index 0000000..0eff3f0 --- /dev/null +++ b/app/worker/single_face_match.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import asyncio + +from app.container import Container +from app.core.config import settings +from app.core.logger import logger +from app.infra.database import engine +from app.infra.nats import NatsClient, NatsSubjects +from app.infra.redis import RedisClient +from app.schema.dto.single_face_match import SingleFaceMatchJob +from app.service.single_face_match import SingleFaceMatchService + + +class SingleFaceMatchWorker: + def __init__(self, service: SingleFaceMatchService) -> None: + self.service = service + + async def handle_message(self, data: bytes) -> None: + try: + job = SingleFaceMatchJob.model_validate_json(data) + except Exception as exc: + logger.warning("Failed to parse single face match job: %s", exc) + return + + try: + await self.service.process_job(job) + except Exception as exc: + logger.exception("Failed to process single face match job: %s", exc) + raise + + +async def run_worker() -> None: + RedisClient( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + password=settings.REDIS_PASSWORD, + ) + + async with engine.connect() as conn: + container = Container(conn) + service = SingleFaceMatchService( + conn=conn, + face_embedding_service=container.face_embedding_service, + photo_face_querier=container.photo_face_querier, + ) + worker = SingleFaceMatchWorker(service) + + await NatsClient.js_subscribe( + subject=NatsSubjects.SINGLE_FACE_MATCH_REQUESTED, + callback=worker.handle_message, + stream_name=settings.NATS_SINGLE_FACE_MATCH_STREAM, + durable_name=settings.NATS_SINGLE_FACE_MATCH_DURABLE, + ) + + logger.info("SingleFaceMatchWorker subscribed; waiting for jobs") + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(run_worker()) From 7a179d4727e308b39692b0307187f81283e2ea62 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 02:38:35 +0100 Subject: [PATCH 36/47] refactor: move single-face worker into folder --- app/worker/single_face_match.py | 61 --------------------------------- 1 file changed, 61 deletions(-) delete mode 100644 app/worker/single_face_match.py diff --git a/app/worker/single_face_match.py b/app/worker/single_face_match.py deleted file mode 100644 index 0eff3f0..0000000 --- a/app/worker/single_face_match.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -import asyncio - -from app.container import Container -from app.core.config import settings -from app.core.logger import logger -from app.infra.database import engine -from app.infra.nats import NatsClient, NatsSubjects -from app.infra.redis import RedisClient -from app.schema.dto.single_face_match import SingleFaceMatchJob -from app.service.single_face_match import SingleFaceMatchService - - -class SingleFaceMatchWorker: - def __init__(self, service: SingleFaceMatchService) -> None: - self.service = service - - async def handle_message(self, data: bytes) -> None: - try: - job = SingleFaceMatchJob.model_validate_json(data) - except Exception as exc: - logger.warning("Failed to parse single face match job: %s", exc) - return - - try: - await self.service.process_job(job) - except Exception as exc: - logger.exception("Failed to process single face match job: %s", exc) - raise - - -async def run_worker() -> None: - RedisClient( - host=settings.REDIS_HOST, - port=settings.REDIS_PORT, - password=settings.REDIS_PASSWORD, - ) - - async with engine.connect() as conn: - container = Container(conn) - service = SingleFaceMatchService( - conn=conn, - face_embedding_service=container.face_embedding_service, - photo_face_querier=container.photo_face_querier, - ) - worker = SingleFaceMatchWorker(service) - - await NatsClient.js_subscribe( - subject=NatsSubjects.SINGLE_FACE_MATCH_REQUESTED, - callback=worker.handle_message, - stream_name=settings.NATS_SINGLE_FACE_MATCH_STREAM, - durable_name=settings.NATS_SINGLE_FACE_MATCH_DURABLE, - ) - - logger.info("SingleFaceMatchWorker subscribed; waiting for jobs") - await asyncio.Event().wait() - - -if __name__ == "__main__": - asyncio.run(run_worker()) From a1ed1fe6c92e8c78e5c0170a12773a7861eb7fcd Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 02:43:16 +0100 Subject: [PATCH 37/47] refactor: move single-face worker into folder --- app/worker/single_face_match/__init__.py | 1 + app/worker/single_face_match/worker.py | 61 ++++++++++++++++++++++++ 2 files changed, 62 insertions(+) create mode 100644 app/worker/single_face_match/__init__.py create mode 100644 app/worker/single_face_match/worker.py diff --git a/app/worker/single_face_match/__init__.py b/app/worker/single_face_match/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/app/worker/single_face_match/__init__.py @@ -0,0 +1 @@ + diff --git a/app/worker/single_face_match/worker.py b/app/worker/single_face_match/worker.py new file mode 100644 index 0000000..0eff3f0 --- /dev/null +++ b/app/worker/single_face_match/worker.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import asyncio + +from app.container import Container +from app.core.config import settings +from app.core.logger import logger +from app.infra.database import engine +from app.infra.nats import NatsClient, NatsSubjects +from app.infra.redis import RedisClient +from app.schema.dto.single_face_match import SingleFaceMatchJob +from app.service.single_face_match import SingleFaceMatchService + + +class SingleFaceMatchWorker: + def __init__(self, service: SingleFaceMatchService) -> None: + self.service = service + + async def handle_message(self, data: bytes) -> None: + try: + job = SingleFaceMatchJob.model_validate_json(data) + except Exception as exc: + logger.warning("Failed to parse single face match job: %s", exc) + return + + try: + await self.service.process_job(job) + except Exception as exc: + logger.exception("Failed to process single face match job: %s", exc) + raise + + +async def run_worker() -> None: + RedisClient( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + password=settings.REDIS_PASSWORD, + ) + + async with engine.connect() as conn: + container = Container(conn) + service = SingleFaceMatchService( + conn=conn, + face_embedding_service=container.face_embedding_service, + photo_face_querier=container.photo_face_querier, + ) + worker = SingleFaceMatchWorker(service) + + await NatsClient.js_subscribe( + subject=NatsSubjects.SINGLE_FACE_MATCH_REQUESTED, + callback=worker.handle_message, + stream_name=settings.NATS_SINGLE_FACE_MATCH_STREAM, + durable_name=settings.NATS_SINGLE_FACE_MATCH_DURABLE, + ) + + logger.info("SingleFaceMatchWorker subscribed; waiting for jobs") + await asyncio.Event().wait() + + +if __name__ == "__main__": + asyncio.run(run_worker()) From d4e3231c0f870879c9ae94d4f356447a025e7ef4 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 03:44:42 +0100 Subject: [PATCH 38/47] feat: harden single-face match processing --- app/service/single_face_match.py | 146 ++++++++++++++++++++++++++----- 1 file changed, 123 insertions(+), 23 deletions(-) diff --git a/app/service/single_face_match.py b/app/service/single_face_match.py index 6b76950..ba51d66 100644 --- a/app/service/single_face_match.py +++ b/app/service/single_face_match.py @@ -1,5 +1,6 @@ from __future__ import annotations +import asyncio import json from dataclasses import dataclass from uuid import UUID @@ -7,7 +8,10 @@ import sqlalchemy import sqlalchemy.ext.asyncio +from app.core.constant import MINIO_URL_PREFIX from app.core.logger import logger +from sqlalchemy.exc import DBAPIError, SQLAlchemyError +from app.infra.minio import Bucket, IMAGES_BUCKET_NAME from app.service.face_embedding import FaceEmbeddingService, FaceImagePayload from app.schema.dto.single_face_match import BBoxPayload, SingleFaceMatchJob from db.generated import photo_faces as photo_face_queries @@ -27,7 +31,7 @@ class ClosestUserMatch: """ GET_CLOSEST_USER = """ -SELECT id, (face_embedding <=> :embedding::vector) AS distance +SELECT id, (face_embedding <=> CAST(:embedding AS vector)) AS distance FROM users WHERE face_embedding IS NOT NULL ORDER BY distance ASC @@ -40,6 +44,24 @@ class ClosestUserMatch: RETURNING id """ +CHECK_MATCH_FOR_PHOTO = """ +SELECT 1 +FROM face_matches fm +JOIN photo_faces pf ON pf.id = fm.photo_face_id +WHERE pf.photo_id = :photo_id +LIMIT 1 +""" + +CHECK_MATCH_FOR_PHOTO_FACE = """ +SELECT 1 +FROM face_matches +WHERE photo_face_id = :photo_face_id +LIMIT 1 +""" + +MINIO_RETRY_ATTEMPTS = 3 +MINIO_RETRY_BASE_SECONDS = 0.5 + class SingleFaceMatchService: def __init__( @@ -62,36 +84,63 @@ async def process_job(self, job: SingleFaceMatchJob) -> None: ) return + if not job.image_ref: + logger.warning("Missing image_ref in event payload for photo %s", job.photo_id) + return + if not await self._photo_exists(job.photo_id): logger.warning("Photo not found: %s", job.photo_id) return + if await self._match_exists_for_photo(job.photo_id): + logger.info("Photo %s already matched; skipping", job.photo_id) + return embedding, bbox = await self._resolve_embedding(job) if embedding is None: return - photo_face = await self._upsert_photo_face( - photo_id=job.photo_id, - face_index=job.face_index, - embedding=embedding, - bbox=bbox, - ) - if photo_face is None: - logger.warning("Failed to upsert photo_face for photo %s", job.photo_id) + try: + photo_face = await self._upsert_photo_face( + photo_id=job.photo_id, + face_index=job.face_index, + embedding=embedding, + bbox=bbox, + ) + if photo_face is None: + logger.warning("Failed to upsert photo_face for photo %s", job.photo_id) + return + await self._commit_best_effort() + except (DBAPIError, SQLAlchemyError) as exc: + await self._rollback_best_effort() + logger.warning("DB write failed for photo %s: %s", job.photo_id, exc) + return + except MemoryError: + logger.error("Out of memory while processing photo %s", job.photo_id) return - await self._commit_best_effort() match = await self._find_closest_user(embedding) if match is None: logger.info("No user embeddings available for matching") return - await self._insert_face_match( - photo_face_id=photo_face.id, - user_id=match.user_id, - confidence=match.distance, - ) - await self._commit_best_effort() + if await self._match_exists_for_photo_face(photo_face.id): + logger.info("Match already exists for photo_face %s; skipping", photo_face.id) + return + + try: + await self._insert_face_match( + photo_face_id=photo_face.id, + user_id=match.user_id, + confidence=match.distance, + ) + await self._commit_best_effort() + except (DBAPIError, SQLAlchemyError) as exc: + await self._rollback_best_effort() + logger.warning("Failed to insert face match for photo %s: %s", job.photo_id, exc) + return + except MemoryError: + logger.error("Out of memory while matching photo %s", job.photo_id) + return async def _photo_exists(self, photo_id: UUID) -> bool: row = (await self.conn.execute( @@ -105,7 +154,7 @@ async def _resolve_embedding( job: SingleFaceMatchJob, ) -> tuple[list[float] | None, BBoxPayload | None]: try: - payload = self._load_payload(job) + payload = await self._load_payload(job) except Exception as exc: logger.warning("Failed to load image payload for photo %s: %s", job.photo_id, exc) return None, None @@ -133,13 +182,34 @@ async def _resolve_embedding( ) return face.embedding, bbox - def _load_payload(self, job: SingleFaceMatchJob) -> FaceImagePayload: - if job.image_ref: - raise NotImplementedError( - "MinIO image loading not implemented. TODO: fetch bytes from image_ref." - ) + async def _load_payload(self, job: SingleFaceMatchJob) -> FaceImagePayload: + if not job.image_ref: + raise ValueError("Missing image_ref in event payload") - raise ValueError("Missing image_ref in event payload") + bucket_name, object_name = self._parse_minio_ref(job.image_ref) + bucket = Bucket(bucket_name, "") + last_exc: Exception | None = None + for attempt in range(1, MINIO_RETRY_ATTEMPTS + 1): + try: + data, filename, content_type = await bucket.get(object_name) + return FaceImagePayload( + filename=filename, + content_type=content_type, + bytes=data, + ) + except Exception as exc: + last_exc = exc + logger.warning( + "MinIO fetch failed for %s (attempt %s/%s): %s", + object_name, + attempt, + MINIO_RETRY_ATTEMPTS, + exc, + ) + if attempt < MINIO_RETRY_ATTEMPTS: + await asyncio.sleep(MINIO_RETRY_BASE_SECONDS * attempt) + assert last_exc is not None + raise last_exc async def _upsert_photo_face( self, @@ -191,12 +261,42 @@ async def _insert_face_match( }, ) + async def _match_exists_for_photo(self, photo_id: UUID) -> bool: + row = (await self.conn.execute( + sqlalchemy.text(CHECK_MATCH_FOR_PHOTO), + {"photo_id": photo_id}, + )).first() + return row is not None + + async def _match_exists_for_photo_face(self, photo_face_id: UUID) -> bool: + row = (await self.conn.execute( + sqlalchemy.text(CHECK_MATCH_FOR_PHOTO_FACE), + {"photo_face_id": photo_face_id}, + )).first() + return row is not None + async def _commit_best_effort(self) -> None: try: await self.conn.commit() except Exception: pass + async def _rollback_best_effort(self) -> None: + try: + await self.conn.rollback() + except Exception: + pass + @staticmethod def _vector_literal(embedding: list[float]) -> str: return "[" + ", ".join(str(x) for x in embedding) + "]" + + @staticmethod + def _parse_minio_ref(image_ref: str) -> tuple[str, str]: + if image_ref.startswith(MINIO_URL_PREFIX): + raw = image_ref[len(MINIO_URL_PREFIX) :] + parts = raw.split("/", 1) + if len(parts) != 2 or not parts[0] or not parts[1]: + raise ValueError("Invalid MinIO image_ref format") + return parts[0], parts[1] + return IMAGES_BUCKET_NAME, image_ref From e3fc3e79ee41d7e8564748e78f74e3d3668a454d Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 03:44:47 +0100 Subject: [PATCH 39/47] chore: improve worker shutdown behavior --- app/worker/single_face_match/worker.py | 28 ++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/app/worker/single_face_match/worker.py b/app/worker/single_face_match/worker.py index 0eff3f0..fa8ef37 100644 --- a/app/worker/single_face_match/worker.py +++ b/app/worker/single_face_match/worker.py @@ -6,6 +6,7 @@ from app.core.config import settings from app.core.logger import logger from app.infra.database import engine +from app.infra.minio import Bucket, init_minio_client from app.infra.nats import NatsClient, NatsSubjects from app.infra.redis import RedisClient from app.schema.dto.single_face_match import SingleFaceMatchJob @@ -27,10 +28,16 @@ async def handle_message(self, data: bytes) -> None: await self.service.process_job(job) except Exception as exc: logger.exception("Failed to process single face match job: %s", exc) - raise + return async def run_worker() -> None: + await init_minio_client( + minio_host=settings.MINIO_HOST, + minio_port=settings.MINIO_API_PORT, + minio_root_user=settings.MINIO_ROOT_USER, + minio_root_password=settings.MINIO_ROOT_PASSWORD, + ) RedisClient( host=settings.REDIS_HOST, port=settings.REDIS_PORT, @@ -54,7 +61,24 @@ async def run_worker() -> None: ) logger.info("SingleFaceMatchWorker subscribed; waiting for jobs") - await asyncio.Event().wait() + try: + await asyncio.Event().wait() + finally: + await _close_minio() + await NatsClient.close() + + +async def _close_minio() -> None: + client = getattr(Bucket, "client", None) + if client is None: + return + close_session = getattr(client, "close_session", None) + if close_session is None: + return + try: + await close_session() + except Exception: + pass if __name__ == "__main__": From a0d73b377bc64ea6d25be6cfb18b03cf89cbe21f Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 03:50:55 +0100 Subject: [PATCH 40/47] refactor: move constants into core --- app/core/constant.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/core/constant.py b/app/core/constant.py index 0a217dd..0925bbd 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -34,6 +34,15 @@ class AuditEventType(str, Enum): DRIVE_ALLOWED_HOSTS = {"drive.google.com", "docs.google.com"} MINIO_URL_PREFIX = "minio://" +IMAGES_BUCKET_NAME = "images" +DOCUMENTS_BUCKET_NAME = "documents" +WA_SIM_BUCKET_NAME = "wa-sim" + +GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" +GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" +GOOGLE_DRIVE_FILES_URL = "https://www.googleapis.com/drive/v3/files/{file_id}" + MAX_IMAGE_SIZE = 5 * 1024 * 1024 MIN_ENROLL_IMAGES = 3 MAX_ENROLL_IMAGES = 5 From cee440f97ec468fedd0f39649e4bc8ff34d3a6a5 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 03:50:56 +0100 Subject: [PATCH 41/47] refactor: move settings into config --- app/core/config.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/core/config.py b/app/core/config.py index c767238..916f0e8 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -25,6 +25,8 @@ class Settings(BaseSettings): MINIO_ROOT_USER: str MINIO_ROOT_PASSWORD: str MINIO_HOST: str + MINIO_RETRY_ATTEMPTS: int = 3 + MINIO_RETRY_BASE_SECONDS: float = 0.5 # PostgreSQL POSTGRES_USER: str @@ -46,6 +48,13 @@ class Settings(BaseSettings): encryption_key: str totp_issuer: str = "multAI" + # Face embedding model + FACE_EMBEDDING_MODEL_NAME: str = "buffalo_l" + FACE_EMBEDDING_PROVIDERS: str = "CPUExecutionProvider" + FACE_EMBEDDING_CTX_ID: int = -1 + FACE_EMBEDDING_DET_WIDTH: int = 640 + FACE_EMBEDDING_DET_HEIGHT: int = 640 + # Google Drive OAuth GOOGLE_CLIENT_ID: str = "" GOOGLE_CLIENT_SECRET: str = "" From 3d352cdc84e38dd67a069eae1ee9e07105c105fe Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 03:50:56 +0100 Subject: [PATCH 42/47] refactor: use core constants for MinIO buckets --- app/infra/minio.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/app/infra/minio.py b/app/infra/minio.py index 2df0f2e..e6249da 100644 --- a/app/infra/minio.py +++ b/app/infra/minio.py @@ -9,12 +9,18 @@ from app.core.utils import check_extension from app.core.exceptions import AppException -from app.core.constant import DEFAULT_CONTENT_TYPE - - -IMAGES_BUCKET_NAME = "images" -DOCUMENTS_BUCKET_NAME = "documents" -WA_SIM_BUCKET_NAME = "wa-sim" +from app.core.constant import ( + DEFAULT_CONTENT_TYPE, + DOCUMENTS_BUCKET_NAME as CORE_DOCUMENTS_BUCKET_NAME, + IMAGES_BUCKET_NAME as CORE_IMAGES_BUCKET_NAME, + WA_SIM_BUCKET_NAME as CORE_WA_SIM_BUCKET_NAME, +) + + +# Re-export bucket names for compatibility with existing imports. +IMAGES_BUCKET_NAME = CORE_IMAGES_BUCKET_NAME +DOCUMENTS_BUCKET_NAME = CORE_DOCUMENTS_BUCKET_NAME +WA_SIM_BUCKET_NAME = CORE_WA_SIM_BUCKET_NAME async def init_minio_client( minio_host: str, minio_port: int, minio_root_user: str, minio_root_password: str From 06c6c64a285cb90550152a3d9b29a8556ed7a0ee Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 03:50:56 +0100 Subject: [PATCH 43/47] refactor: use core constants for Google URLs --- app/infra/google_drive.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/infra/google_drive.py b/app/infra/google_drive.py index 0b32ad6..4816ea5 100644 --- a/app/infra/google_drive.py +++ b/app/infra/google_drive.py @@ -9,12 +9,14 @@ from app.core.exceptions import AppException from app.core.config import settings +from app.core.constant import ( + GOOGLE_AUTH_URL, + GOOGLE_DRIVE_FILES_URL, + GOOGLE_TOKEN_URL, + GOOGLE_USERINFO_URL, +) -GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" -GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token" -GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo" -GOOGLE_DRIVE_FILES_URL = "https://www.googleapis.com/drive/v3/files/{file_id}" @dataclass From 6282fe3d2f63e9f03f8e4335af6a1605cc5d32d6 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 03:50:56 +0100 Subject: [PATCH 44/47] refactor: load face model settings from config --- app/service/face_embedding.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/app/service/face_embedding.py b/app/service/face_embedding.py index d644906..45a5475 100644 --- a/app/service/face_embedding.py +++ b/app/service/face_embedding.py @@ -7,6 +7,7 @@ import cv2 # type: ignore import numpy as np from insightface.app import FaceAnalysis # type: ignore[import-untyped] +from app.core.config import settings from app.core.exceptions import AppException @@ -37,15 +38,26 @@ class DetectedFace: class FaceEmbedding: def __init__( self, - model_name: str = "buffalo_l", - providers: Sequence[str] = ("CPUExecutionProvider",), - ctx_id: int = -1, - det_size: Tuple[int, int] = (640, 640), + model_name: str | None = None, + providers: Sequence[str] | None = None, + ctx_id: int | None = None, + det_size: Tuple[int, int] | None = None, ) -> None: self.model: FaceAnalysis | None = None - self.model_name = model_name + self.model_name = model_name or settings.FACE_EMBEDDING_MODEL_NAME + if providers is None: + providers = tuple( + p.strip() + for p in settings.FACE_EMBEDDING_PROVIDERS.split(",") + if p.strip() + ) self.providers = providers - self.ctx_id = ctx_id + self.ctx_id = settings.FACE_EMBEDDING_CTX_ID if ctx_id is None else ctx_id + if det_size is None: + det_size = ( + settings.FACE_EMBEDDING_DET_WIDTH, + settings.FACE_EMBEDDING_DET_HEIGHT, + ) self.det_size = det_size self._initialized = False From 4bb91fc1646345ca931c8385e7aa8d2b8b6dce8d Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 03:50:56 +0100 Subject: [PATCH 45/47] refactor: use config for MinIO retries --- app/service/single_face_match.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/service/single_face_match.py b/app/service/single_face_match.py index ba51d66..a8c1b98 100644 --- a/app/service/single_face_match.py +++ b/app/service/single_face_match.py @@ -9,6 +9,7 @@ import sqlalchemy.ext.asyncio from app.core.constant import MINIO_URL_PREFIX +from app.core.config import settings from app.core.logger import logger from sqlalchemy.exc import DBAPIError, SQLAlchemyError from app.infra.minio import Bucket, IMAGES_BUCKET_NAME @@ -59,8 +60,6 @@ class ClosestUserMatch: LIMIT 1 """ -MINIO_RETRY_ATTEMPTS = 3 -MINIO_RETRY_BASE_SECONDS = 0.5 class SingleFaceMatchService: @@ -189,7 +188,7 @@ async def _load_payload(self, job: SingleFaceMatchJob) -> FaceImagePayload: bucket_name, object_name = self._parse_minio_ref(job.image_ref) bucket = Bucket(bucket_name, "") last_exc: Exception | None = None - for attempt in range(1, MINIO_RETRY_ATTEMPTS + 1): + for attempt in range(1, settings.MINIO_RETRY_ATTEMPTS + 1): try: data, filename, content_type = await bucket.get(object_name) return FaceImagePayload( @@ -203,11 +202,11 @@ async def _load_payload(self, job: SingleFaceMatchJob) -> FaceImagePayload: "MinIO fetch failed for %s (attempt %s/%s): %s", object_name, attempt, - MINIO_RETRY_ATTEMPTS, + settings.MINIO_RETRY_ATTEMPTS, exc, ) - if attempt < MINIO_RETRY_ATTEMPTS: - await asyncio.sleep(MINIO_RETRY_BASE_SECONDS * attempt) + if attempt < settings.MINIO_RETRY_ATTEMPTS: + await asyncio.sleep(settings.MINIO_RETRY_BASE_SECONDS * attempt) assert last_exc is not None raise last_exc From edef8f5f39564f16ff3cc0526878825de6baa4e6 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 20:37:06 +0100 Subject: [PATCH 46/47] WIP: save work before rebase --- app/service/users.py | 180 +++++++++++-------------------------------- 1 file changed, 45 insertions(+), 135 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index 9c07019..f892657 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -4,7 +4,6 @@ from app.core import constant from app.core.exceptions import AppException, DBException from app.core.securite import ( - # EmbeddingCrypto, hash_password, verify_password, create_acces_mobile_token, @@ -14,7 +13,6 @@ ) from app.core.config import settings from app.infra.redis import RedisClient -from app.infra.redis import RedisClient from app.schema.request.mobile.auth import MobileAuthRequest from app.schema.response.mobile.auth import MobileAuthResponse @@ -144,7 +142,7 @@ async def mobile_register_login( return MobileAuthResponse( access_token=access_token, refresh_token=refresh_token, - session_id=str(session.id), + session_id=str(session.id), expires_in=expiry, ) @@ -184,6 +182,50 @@ async def refresh_token( expires_in=expiry, ) + async def logout( + self, + redis: RedisClient, + user_id: str, + session_id: str, + ) -> dict[str, str]: + session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id) + await redis.delete(session_key) + return {"message": "Logged out successfully"} + + async def add_embbed_user( + self, + user_id: uuid.UUID, + image_payloads: list[FaceImagePayload], + ) -> User: + logger.info("Generating face embeddings for user %s", user_id) + + averaging = await self.face_embedding_service.compute_average_embedding( + image_payloads + ) + vector_literal = "[" + ", ".join(str(x) for x in averaging) + "]" + user = await self.user_querier.set_user_embedding( + dollar_1=vector_literal, + id=user_id, + ) + if not user: + raise AppException.internal_error("Failed to set user embedding") + + return user + + async def validate_session( + self, + redis: RedisClient, + session_id: str, + ) -> bool: + session = await self.session_querier.get_session_by_id(id=uuid.UUID(session_id)) + + if not session: + return False + + if session.expires_at < datetime.now(timezone.utc): + return False + return True + async def get_user_by_id(self, user_id: uuid.UUID) -> User | None: return await self.user_querier.get_user_by_id(id=user_id) @@ -195,16 +237,11 @@ async def create_user( display_name: str | None = None, blocked: bool = False, ) -> User: -<<<<<<< HEAD -<<<<<<< HEAD -======= ->>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) try: hashed = hash_password(password) user = await self.user_querier.create_user( email=email, hashed_password=hashed, -<<<<<<< HEAD ) if not user: raise AppException.internal_error("Failed to create user") @@ -224,49 +261,6 @@ async def create_user( except Exception as exc: logger.error("Failed to create user: %s", exc) raise DBException.handle(exc) -======= - hashed = hash_password(password) - user = await self.user_querier.create_user( - email=email, - hashed_password=hashed, - ) - if not user: - raise AppException.internal_error("Failed to create user") - - if display_name is not None or blocked: - updated = await self.user_querier.update_user( - email=user.email, - display_name=display_name, - blocked=blocked, - id=user.id, -======= ->>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) - ) - if not user: - raise AppException.internal_error("Failed to create user") - -<<<<<<< HEAD - return user ->>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) -======= - if display_name is not None or blocked: - updated = await self.user_querier.update_user( - email=user.email, - display_name=display_name, - blocked=blocked, - id=user.id, - ) - if not updated: - raise AppException.internal_error("Failed to update user") - return updated - - return user - except AppException: - raise - except Exception as exc: - logger.error("Failed to create user: %s", exc) - raise DBException.handle(exc) ->>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) async def get_user(self, *, user_id: uuid.UUID) -> User: user = await self.user_querier.get_user_by_id(id=user_id) @@ -275,10 +269,6 @@ async def get_user(self, *, user_id: uuid.UUID) -> User: return user async def list_users(self, *, limit: int, offset: int) -> list[User]: -<<<<<<< HEAD -<<<<<<< HEAD -======= ->>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) try: users: list[User] = [] async for user in self.user_querier.list_users(limit=limit, offset=offset): @@ -287,15 +277,6 @@ async def list_users(self, *, limit: int, offset: int) -> list[User]: except Exception as exc: logger.error("Failed to list users: %s", exc) raise DBException.handle(exc) -<<<<<<< HEAD -======= - users: list[User] = [] - async for user in self.user_querier.list_users(limit=limit, offset=offset): - users.append(user) - return users ->>>>>>> 138b7c6 (Add token blacklist and blocked checks in auth) -======= ->>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) async def update_user( self, @@ -305,15 +286,10 @@ async def update_user( display_name: str | None = None, blocked: bool | None = None, ) -> User: -<<<<<<< HEAD -<<<<<<< HEAD -======= ->>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) try: existing = await self.user_querier.get_user_by_id(id=user_id) if not existing: raise AppException.not_found("User not found") -<<<<<<< HEAD new_email = email if email is not None else existing.email new_display_name = ( @@ -374,69 +350,3 @@ async def unblock_user(self, *, user_id: uuid.UUID) -> User: except Exception as exc: logger.error("Failed to unblock user: %s", exc) raise DBException.handle(exc) -======= - existing = await self.user_querier.get_user_by_id(id=user_id) - if not existing: - raise AppException.not_found("User not found") -======= ->>>>>>> bbb4ecd (Use settings and consistent DB error handling in user service) - - new_email = email if email is not None else existing.email - new_display_name = ( - display_name if display_name is not None else existing.display_name - ) - new_blocked = blocked if blocked is not None else existing.blocked - - user = await self.user_querier.update_user( - email=new_email, - display_name=new_display_name, - blocked=new_blocked, - id=user_id, - ) - if not user: - raise AppException.internal_error("Failed to update user") - return user - except AppException: - raise - except Exception as exc: - logger.error("Failed to update user: %s", exc) - raise DBException.handle(exc) - - async def delete_user(self, *, user_id: uuid.UUID) -> User: - try: - existing = await self.user_querier.get_user_by_id(id=user_id) - if not existing: - raise AppException.not_found("User not found") - await self.user_querier.delete_user(id=user_id) - return existing - except AppException: - raise - except Exception as exc: - logger.error("Failed to delete user: %s", exc) - raise DBException.handle(exc) - - async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: - try: - user = await self.user_querier.set_user_blocked(blocked=True, id=user_id) - if not user: - raise AppException.not_found("User not found") - - session_key = constant.RedisKey.UserSessionByUser.value.format( - user_id=user_id - ) - await redis.delete(session_key) - - return user - except Exception as exc: - logger.error("Failed to block user: %s", exc) - raise DBException.handle(exc) - - async def unblock_user(self, *, user_id: uuid.UUID) -> User: - try: - user = await self.user_querier.set_user_blocked(blocked=False, id=user_id) - if not user: - raise AppException.not_found("User not found") - return user - except Exception as exc: - logger.error("Failed to unblock user: %s", exc) - raise DBException.handle(exc) From 3bb3bad08db5c5f9192ef7baee84a27563d0f690 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Wed, 25 Mar 2026 21:27:26 +0100 Subject: [PATCH 47/47] Bon. --- app/core/config.py | 12 +++ app/core/exceptions.py | 3 + app/deps/token_auth.py | 4 - app/router/web/users.py | 73 ---------------- app/schema/response/web/user.py | 6 -- app/service/face_embedding.py | 6 -- app/service/session.py | 2 + app/service/single_face_match.py | 2 +- app/service/users.py | 2 +- db/generated/user.py | 139 ++++++++++++++----------------- 10 files changed, 83 insertions(+), 166 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 916f0e8..55d0841 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,4 +1,5 @@ from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import field_validator class Settings(BaseSettings): @@ -71,5 +72,16 @@ class Settings(BaseSettings): extra="ignore", ) + @field_validator("debug", mode="before") + @classmethod + def _parse_debug(cls, value): # type: ignore[no-untyped-def] + if isinstance(value, str): + lowered = value.strip().lower() + if lowered in {"release", "prod", "production", "false", "0", "no"}: + return False + if lowered in {"true", "1", "yes"}: + return True + return value + settings = Settings() # type: ignore diff --git a/app/core/exceptions.py b/app/core/exceptions.py index 5f4a9b5..ddf0d36 100644 --- a/app/core/exceptions.py +++ b/app/core/exceptions.py @@ -75,6 +75,9 @@ def handle_check_violation(exc: Exception) -> HTTPException: def handle(exc: Exception) -> HTTPException: logger.error("Database error: %s", exc) + if isinstance(exc, HTTPException): + return exc + if isinstance(exc, IntegrityError): orig = getattr(exc, "orig", None) sqlstate = getattr(orig, "sqlstate", None) diff --git a/app/deps/token_auth.py b/app/deps/token_auth.py index 527e28b..a7eff17 100644 --- a/app/deps/token_auth.py +++ b/app/deps/token_auth.py @@ -5,7 +5,6 @@ from pydantic import BaseModel from app.container import get_container, Container from app.core.securite import decode_access_mobile_token -from app.core.token_blacklist import is_session_blacklisted security = HTTPBearer() @@ -32,9 +31,6 @@ async def get_current_mobile_user( session_id = uuid.UUID(session_id_str) - if await is_session_blacklisted(container.redis, session_id_str): - raise HTTPException(status_code=401, detail="Token is blacklisted") - # Validate session via SessionService session = await container.session_service.session_querier.get_session_by_id(id=session_id) if not session: diff --git a/app/router/web/users.py b/app/router/web/users.py index 751631d..f167376 100644 --- a/app/router/web/users.py +++ b/app/router/web/users.py @@ -32,28 +32,12 @@ async def list_users( limit: int = Query( settings.ADMIN_USERS_DEFAULT_LIMIT, ge=1, le=settings.ADMIN_USERS_MAX_LIMIT ), - limit: int = Query(20, ge=1, le=100), ->>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) offset: int = Query(0, ge=0), current_staff_user: StaffUser = Depends(get_current_staff_user), container: Container = Depends(get_container), ) -> list[AdminUserSchema]: users = await container.auth_service.list_users(limit=limit, offset=offset) -<<<<<<< HEAD return [to_admin_user_schema(user) for user in users] -======= - return [ - AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) - for user in users - ] ->>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) @router.get("/{user_id}", response_model=AdminUserSchema) @@ -63,18 +47,7 @@ async def get_user( container: Container = Depends(get_container), ) -> AdminUserSchema: user = await container.auth_service.get_user(user_id=user_id) -<<<<<<< HEAD return to_admin_user_schema(user) -======= - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) ->>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) @router.put("/{user_id}", response_model=AdminUserSchema) @@ -91,18 +64,7 @@ async def update_user( blocked=req.blocked, ) logger.info("admin %s updated user %s", current_staff_user.id, user_id) -<<<<<<< HEAD return to_admin_user_schema(user) -======= - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) ->>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) @router.delete("/{user_id}", response_model=AdminUserSchema) @@ -111,25 +73,12 @@ async def delete_user( current_staff_user: StaffUser = Depends(get_current_staff_user), container: Container = Depends(get_container), ) -> AdminUserSchema: -<<<<<<< HEAD user = await container.auth_service.delete_user( redis=container.redis, user_id=user_id, ) logger.info("admin %s deleted user %s", current_staff_user.id, user_id) return to_admin_user_schema(user) -======= - user = await container.auth_service.delete_user(user_id=user_id) - logger.info("admin %s deleted user %s", current_staff_user.id, user_id) - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) ->>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) @router.post("/{user_id}/block", response_model=AdminUserSchema) @@ -143,18 +92,7 @@ async def block_user( user_id=user_id, ) logger.info("admin %s blocked user %s", current_staff_user.id, user_id) -<<<<<<< HEAD return to_admin_user_schema(user) -======= - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) ->>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) @router.post("/{user_id}/unblock", response_model=AdminUserSchema) @@ -165,15 +103,4 @@ async def unblock_user( ) -> AdminUserSchema: user = await container.auth_service.unblock_user(user_id=user_id) logger.info("admin %s unblocked user %s", current_staff_user.id, user_id) -<<<<<<< HEAD return to_admin_user_schema(user) -======= - return AdminUserSchema( - id=user.id, - email=user.email, - display_name=user.display_name, - blocked=user.blocked, - created_at=user.created_at, - updated_at=user.updated_at, - ) ->>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) diff --git a/app/schema/response/web/user.py b/app/schema/response/web/user.py index a3bc0ba..bd79627 100644 --- a/app/schema/response/web/user.py +++ b/app/schema/response/web/user.py @@ -2,10 +2,7 @@ from uuid import UUID from pydantic import BaseModel -<<<<<<< HEAD from db.generated.models import User -======= ->>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) class AdminUserSchema(BaseModel): @@ -15,7 +12,6 @@ class AdminUserSchema(BaseModel): blocked: bool created_at: datetime updated_at: datetime -<<<<<<< HEAD def to_admin_user_schema(user: User) -> AdminUserSchema: @@ -27,5 +23,3 @@ def to_admin_user_schema(user: User) -> AdminUserSchema: created_at=user.created_at, updated_at=user.updated_at, ) -======= ->>>>>>> 8ce7e61 (Add admin user CRUD and block/unblock endpoints) diff --git a/app/service/face_embedding.py b/app/service/face_embedding.py index 45a5475..71295e3 100644 --- a/app/service/face_embedding.py +++ b/app/service/face_embedding.py @@ -125,12 +125,6 @@ def embed(self, image: np.ndarray, bboxes: Sequence[BBox]) -> list[float]: return embedding.tolist() -class DetectedFace: - def __init__(self, embedding: list[float], bbox: tuple[float, float, float, float]): - self.embedding = embedding - self.bbox = bbox - - class FaceEmbeddingService: def __init__(self, face_embedding: FaceEmbedding | None = None) -> None: self.face_embedding = face_embedding or FaceEmbedding() diff --git a/app/service/session.py b/app/service/session.py index d0792d7..e441fc9 100644 --- a/app/service/session.py +++ b/app/service/session.py @@ -22,6 +22,8 @@ class SessionService : def init(self, session: session_queries.AsyncQuerier, redis: RedisClient) -> None: self.session_querier = session self.redis = redis + SessionService.session_querier = session + SessionService.redis = redis @staticmethod async def create_session(user_id:uuid.UUID,device_id:uuid.UUID)->UpsertSessionRow: diff --git a/app/service/single_face_match.py b/app/service/single_face_match.py index a8c1b98..30676c1 100644 --- a/app/service/single_face_match.py +++ b/app/service/single_face_match.py @@ -74,7 +74,7 @@ def __init__( self.face_embedding_service = face_embedding_service self.photo_face_querier = photo_face_querier - async def process_job(self, job: SingleFaceMatchJob) -> None: + async def process_job(self, job: SingleFaceMatchJob) -> None: # noqa: C901 if job.faces_detected is not None and job.faces_detected != 1: logger.info( "Skipping photo %s: faces_detected=%s (single-face worker)", diff --git a/app/service/users.py b/app/service/users.py index f892657..8ab3f81 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -1,7 +1,6 @@ from datetime import datetime, timedelta, timezone import uuid -from app.core import constant from app.core.exceptions import AppException, DBException from app.core.securite import ( hash_password, @@ -11,6 +10,7 @@ decode_refresh_mobile_token, Get_expiry_time, ) +from app.core import constant from app.core.config import settings from app.infra.redis import RedisClient diff --git a/db/generated/user.py b/db/generated/user.py index ddb8ffc..e812192 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -14,11 +14,7 @@ CREATE_USER = """-- name: create_user \\:one INSERT INTO users (email, hashed_password) VALUES (:p1, :p2) -<<<<<<< HEAD -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked -======= -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked ->>>>>>> 7ef7381 (chore: update generated db queriers) +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at """ @@ -29,33 +25,21 @@ GET_USER_BY_EMAIL = """-- name: get_user_by_email \\:one -<<<<<<< HEAD -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked -======= -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked ->>>>>>> 7ef7381 (chore: update generated db queriers) +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at FROM users WHERE email = :p1 """ GET_USER_BY_ID = """-- name: get_user_by_id \\:one -<<<<<<< HEAD -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked -======= -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked ->>>>>>> 7ef7381 (chore: update generated db queriers) +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at FROM users WHERE id = :p1 """ LIST_USERS = """-- name: list_users \\:many -<<<<<<< HEAD -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked -======= -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked ->>>>>>> 7ef7381 (chore: update generated db queriers) +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at FROM users ORDER BY created_at DESC LIMIT :p1 OFFSET :p2 @@ -76,7 +60,6 @@ SET face_embedding = :p1\\:\\:vector, updated_at = NOW() WHERE id = :p2 -<<<<<<< HEAD RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at """ @@ -89,9 +72,6 @@ updated_at = NOW() WHERE id = :p4 RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at -======= -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked ->>>>>>> 7ef7381 (chore: update generated db queriers) """ @@ -100,11 +80,7 @@ SET hashed_password = :p1, updated_at = NOW() WHERE id = :p2 -<<<<<<< HEAD RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at -======= -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked ->>>>>>> 7ef7381 (chore: update generated db queriers) """ @@ -112,8 +88,14 @@ class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn - async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Optional[models.User]: - row = (await self._conn.execute(sqlalchemy.text(CREATE_USER), {"p1": email, "p2": hashed_password})).first() + async def create_user( + self, *, email: str, hashed_password: Optional[str] + ) -> Optional[models.User]: + row = ( + await self._conn.execute( + sqlalchemy.text(CREATE_USER), {"p1": email, "p2": hashed_password} + ) + ).first() if row is None: return None return models.User( @@ -124,20 +106,19 @@ async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Op updated_at=row[4], display_name=row[5], face_embedding=row[6], -<<<<<<< HEAD blocked=row[7], deleted_at=row[8], -======= - deleted_at=row[7], - blocked=row[8], ->>>>>>> 7ef7381 (chore: update generated db queriers) ) async def delete_user(self, *, id: uuid.UUID) -> None: await self._conn.execute(sqlalchemy.text(DELETE_USER), {"p1": id}) async def get_user_by_email(self, *, email: str) -> Optional[models.User]: - row = (await self._conn.execute(sqlalchemy.text(GET_USER_BY_EMAIL), {"p1": email})).first() + row = ( + await self._conn.execute( + sqlalchemy.text(GET_USER_BY_EMAIL), {"p1": email} + ) + ).first() if row is None: return None return models.User( @@ -148,17 +129,14 @@ async def get_user_by_email(self, *, email: str) -> Optional[models.User]: updated_at=row[4], display_name=row[5], face_embedding=row[6], -<<<<<<< HEAD blocked=row[7], deleted_at=row[8], -======= - deleted_at=row[7], - blocked=row[8], ->>>>>>> 7ef7381 (chore: update generated db queriers) ) async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: - row = (await self._conn.execute(sqlalchemy.text(GET_USER_BY_ID), {"p1": id})).first() + row = ( + await self._conn.execute(sqlalchemy.text(GET_USER_BY_ID), {"p1": id}) + ).first() if row is None: return None return models.User( @@ -169,17 +147,16 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: updated_at=row[4], display_name=row[5], face_embedding=row[6], -<<<<<<< HEAD blocked=row[7], deleted_at=row[8], -======= - deleted_at=row[7], - blocked=row[8], ->>>>>>> 7ef7381 (chore: update generated db queriers) ) - async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: - result = await self._conn.stream(sqlalchemy.text(LIST_USERS), {"p1": limit, "p2": offset}) + async def list_users( + self, *, limit: int, offset: int + ) -> AsyncIterator[models.User]: + result = await self._conn.stream( + sqlalchemy.text(LIST_USERS), {"p1": limit, "p2": offset} + ) async for row in result: yield models.User( id=row[0], @@ -189,17 +166,16 @@ async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.U updated_at=row[4], display_name=row[5], face_embedding=row[6], -<<<<<<< HEAD blocked=row[7], deleted_at=row[8], -======= - deleted_at=row[7], - blocked=row[8], ->>>>>>> 7ef7381 (chore: update generated db queriers) ) async def set_user_blocked(self, *, blocked: bool, id: uuid.UUID) -> Optional[models.User]: - row = (await self._conn.execute(sqlalchemy.text(SET_USER_BLOCKED), {"p1": blocked, "p2": id})).first() + row = ( + await self._conn.execute( + sqlalchemy.text(SET_USER_BLOCKED), {"p1": blocked, "p2": id} + ) + ).first() if row is None: return None return models.User( @@ -215,7 +191,11 @@ async def set_user_blocked(self, *, blocked: bool, id: uuid.UUID) -> Optional[mo ) async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[models.User]: - row = (await self._conn.execute(sqlalchemy.text(SET_USER_EMBEDDING), {"p1": dollar_1, "p2": id})).first() + row = ( + await self._conn.execute( + sqlalchemy.text(SET_USER_EMBEDDING), {"p1": dollar_1, "p2": id} + ) + ).first() if row is None: return None return models.User( @@ -226,18 +206,29 @@ async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[ updated_at=row[4], display_name=row[5], face_embedding=row[6], -<<<<<<< HEAD blocked=row[7], deleted_at=row[8], ) - async def update_user(self, *, email: str, display_name: Optional[str], blocked: bool, id: uuid.UUID) -> Optional[models.User]: - row = (await self._conn.execute(sqlalchemy.text(UPDATE_USER), { - "p1": email, - "p2": display_name, - "p3": blocked, - "p4": id, - })).first() + async def update_user( + self, + *, + email: str, + display_name: Optional[str], + blocked: bool, + id: uuid.UUID, + ) -> Optional[models.User]: + row = ( + await self._conn.execute( + sqlalchemy.text(UPDATE_USER), + { + "p1": email, + "p2": display_name, + "p3": blocked, + "p4": id, + }, + ) + ).first() if row is None: return None return models.User( @@ -250,14 +241,17 @@ async def update_user(self, *, email: str, display_name: Optional[str], blocked: face_embedding=row[6], blocked=row[7], deleted_at=row[8], -======= - deleted_at=row[7], - blocked=row[8], ->>>>>>> 7ef7381 (chore: update generated db queriers) ) - async def update_user_password(self, *, hashed_password: Optional[str], id: uuid.UUID) -> Optional[models.User]: - row = (await self._conn.execute(sqlalchemy.text(UPDATE_USER_PASSWORD), {"p1": hashed_password, "p2": id})).first() + async def update_user_password( + self, *, hashed_password: Optional[str], id: uuid.UUID + ) -> Optional[models.User]: + row = ( + await self._conn.execute( + sqlalchemy.text(UPDATE_USER_PASSWORD), + {"p1": hashed_password, "p2": id}, + ) + ).first() if row is None: return None return models.User( @@ -268,11 +262,6 @@ async def update_user_password(self, *, hashed_password: Optional[str], id: uuid updated_at=row[4], display_name=row[5], face_embedding=row[6], -<<<<<<< HEAD blocked=row[7], deleted_at=row[8], -======= - deleted_at=row[7], - blocked=row[8], ->>>>>>> 7ef7381 (chore: update generated db queriers) )