From 70288699037dd0bb7a0fda297ba5dfce54cbc7fc Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Thu, 19 Mar 2026 13:59:32 +0100 Subject: [PATCH 01/26] add staged upload review workflow --- app/container.py | 4 + app/infra/google_drive.py | 104 ++++- app/infra/minio.py | 43 ++- app/infra/nats.py | 3 + app/router/staff/__init__.py | 8 +- app/router/staff/uploads.py | 44 +++ app/schema/dto/staff/uploads.py | 3 - app/schema/request/staff/uploads.py | 36 +- app/schema/response/staff/uploads.py | 21 + app/service/staff_drive.py | 13 + app/service/staged_upload_storage.py | 102 +++++ app/service/upload_requests.py | 365 +++++++++++++++--- db/generated/models.py | 8 +- db/generated/upload_request_photos.py | 190 ++++++++- db/queries/staff_notifications.sql | 23 ++ db/queries/upload_request_photos.sql | 50 +++ .../sql/down/add-staff-upload-review-base.sql | 19 + .../sql/up/add-staff-upload-review-base.sql | 44 +++ ...b8d0f1e2a4_add_staff_upload_review_base.py | 25 ++ 19 files changed, 986 insertions(+), 119 deletions(-) create mode 100644 app/service/staged_upload_storage.py create mode 100644 db/queries/staff_notifications.sql create mode 100644 db/queries/upload_request_photos.sql create mode 100644 migrations/sql/down/add-staff-upload-review-base.sql create mode 100644 migrations/sql/up/add-staff-upload-review-base.sql create mode 100644 migrations/versions/c3b8d0f1e2a4_add_staff_upload_review_base.py diff --git a/app/container.py b/app/container.py index 2c921b7..d1f02a7 100644 --- a/app/container.py +++ b/app/container.py @@ -7,6 +7,7 @@ from app.service.device import DeviceService from app.service.face_embedding import FaceEmbeddingService from app.service.session import SessionService +from app.service.staged_upload_storage import StagedUploadStorageService from app.service.staff_drive import StaffDriveService from app.service.staff_notifications import StaffNotificationsService from app.service.staff_user import StaffUserService @@ -71,11 +72,14 @@ def __init__( self.staff_notifications_service = StaffNotificationsService( notification_querier=self.staff_notification_querier, ) + self.staged_upload_storage_service = StagedUploadStorageService() self.upload_requests_service = UploadRequestsService( upload_request_querier=self.upload_request_querier, upload_request_photo_querier=self.upload_request_photo_querier, photo_querier=self.photo_querier, + staged_upload_storage=self.staged_upload_storage_service, + staff_drive_service=self.staff_drive_service, staff_notifications_service=self.staff_notifications_service, ) diff --git a/app/infra/google_drive.py b/app/infra/google_drive.py index a51413f..0b32ad6 100644 --- a/app/infra/google_drive.py +++ b/app/infra/google_drive.py @@ -5,6 +5,7 @@ import urllib.request from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from email.message import Message from app.core.exceptions import AppException from app.core.config import settings @@ -13,6 +14,7 @@ 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 @@ -31,6 +33,20 @@ class GoogleUserInfo: verified_email: bool +@dataclass +class GoogleDriveFileMetadata: + id: str + name: str + mime_type: str + size_bytes: int + + +@dataclass +class GoogleDriveFileDownload: + metadata: GoogleDriveFileMetadata + content: bytes + + class GoogleDriveClient: @staticmethod def _require_str(data: dict[str, object], key: str) -> str: @@ -107,6 +123,7 @@ async def get_user_info(access_token: str) -> GoogleUserInfo: data = await GoogleDriveClient._get_json( GOOGLE_USERINFO_URL, headers={"Authorization": f"Bearer {access_token}"}, + error_context="Google user info request", ) return GoogleUserInfo( id=GoogleDriveClient._require_str(data, "id"), @@ -114,6 +131,56 @@ async def get_user_info(access_token: str) -> GoogleUserInfo: verified_email=bool(data.get("verified_email", False)), ) + @staticmethod + async def get_file_metadata( + *, + access_token: str, + file_id: str, + ) -> GoogleDriveFileMetadata: + data = await GoogleDriveClient._get_json( + GOOGLE_DRIVE_FILES_URL.format(file_id=urllib.parse.quote(file_id, safe="")), + headers={"Authorization": f"Bearer {access_token}"}, + query_params={ + "fields": "id,name,mimeType,size", + "supportsAllDrives": "true", + }, + error_context="Google Drive file metadata request", + ) + size_raw = data.get("size", "0") + if not isinstance(size_raw, (str, int)): + raise AppException.bad_request("Google Drive file size is invalid") + try: + size_bytes = int(size_raw) + except (TypeError, ValueError) as exc: + raise AppException.bad_request("Google Drive file size is invalid") from exc + + return GoogleDriveFileMetadata( + id=GoogleDriveClient._require_str(data, "id"), + name=GoogleDriveClient._require_str(data, "name"), + mime_type=GoogleDriveClient._require_str(data, "mimeType"), + size_bytes=size_bytes, + ) + + @staticmethod + async def download_file( + *, + access_token: str, + file_id: str, + ) -> GoogleDriveFileDownload: + metadata = await GoogleDriveClient.get_file_metadata( + access_token=access_token, + file_id=file_id, + ) + content, _, _ = await GoogleDriveClient._get_bytes( + GOOGLE_DRIVE_FILES_URL.format(file_id=urllib.parse.quote(file_id, safe="")), + headers={"Authorization": f"Bearer {access_token}"}, + query_params={ + "alt": "media", + "supportsAllDrives": "true", + }, + ) + return GoogleDriveFileDownload(metadata=metadata, content=content) + @staticmethod async def _post_form(url: str, payload: dict[str, str]) -> dict[str, object]: encoded = urllib.parse.urlencode(payload).encode("utf-8") @@ -144,16 +211,21 @@ def _request() -> dict[str, object]: async def _get_json( url: str, headers: dict[str, str] | None = None, + query_params: dict[str, str] | None = None, + error_context: str = "Google API request", ) -> dict[str, object]: def _request() -> dict[str, object]: - request = urllib.request.Request(url, headers=headers or {}, method="GET") + final_url = url + if query_params: + final_url = f"{url}?{urllib.parse.urlencode(query_params)}" + request = urllib.request.Request(final_url, headers=headers or {}, method="GET") try: with urllib.request.urlopen(request, timeout=15) as response: return json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: details = exc.read().decode("utf-8", errors="ignore") raise AppException.bad_request( - f"Google user info request failed: {details or exc.reason}" + f"{error_context} failed: {details or exc.reason}" ) from exc except urllib.error.URLError as exc: raise AppException.internal_error( @@ -161,3 +233,31 @@ def _request() -> dict[str, object]: ) from exc return await asyncio.to_thread(_request) + + @staticmethod + async def _get_bytes( + url: str, + headers: dict[str, str] | None = None, + query_params: dict[str, str] | None = None, + ) -> tuple[bytes, str, str]: + def _request() -> tuple[bytes, str, str]: + final_url = url + if query_params: + final_url = f"{url}?{urllib.parse.urlencode(query_params)}" + request = urllib.request.Request(final_url, headers=headers or {}, method="GET") + try: + with urllib.request.urlopen(request, timeout=30) as response: + body = response.read() + response_headers: Message = response.headers + content_type = response_headers.get_content_type() + file_name = response_headers.get_filename() or "" + return body, content_type, file_name + except urllib.error.HTTPError as exc: + details = exc.read().decode("utf-8", errors="ignore") + raise AppException.bad_request( + f"Google file download failed: {details or exc.reason}" + ) from exc + except urllib.error.URLError as exc: + raise AppException.internal_error("Unable to download file from Google Drive") from exc + + return await asyncio.to_thread(_request) diff --git a/app/infra/minio.py b/app/infra/minio.py index 162e0f0..09104ea 100644 --- a/app/infra/minio.py +++ b/app/infra/minio.py @@ -1,7 +1,9 @@ +import io import random import string import uuid from fastapi import UploadFile +from miniopy_async.commonconfig import CopySource from miniopy_async.error import S3Error from miniopy_async.api import Minio @@ -36,6 +38,11 @@ def __init__(self, bucket_name: str, file_prefix: str): self.bucket_name = bucket_name self.file_prefix = file_prefix + def _object_path(self, object_name: str) -> str: + if self.file_prefix: + return f"{self.file_prefix}/{object_name}" + return object_name + async def put(self, file: UploadFile, object_name: str | None = None) -> str: if object_name is None: object_name = str(uuid.uuid4()) @@ -48,7 +55,7 @@ async def put(self, file: UploadFile, object_name: str | None = None) -> str: await self.client.put_object( bucket_name=self.bucket_name, - object_name=f"{self.file_prefix}/{object_name}", + object_name=self._object_path(object_name), data=file.file, length=-1, part_size=10 * 1024 * 1024, @@ -63,7 +70,7 @@ async def get(self, object_name: str) -> tuple[bytes, str, str]: try: res = await self.client.get_object( bucket_name=self.bucket_name, - object_name=f"{self.file_prefix}/{object_name}", + object_name=self._object_path(object_name), ) except S3Error as e: if e.code == "NoSuchKey": @@ -83,8 +90,38 @@ async def get(self, object_name: str) -> tuple[bytes, str, str]: async def delete(self, object_name: str) -> None: await self.client.remove_object( bucket_name=self.bucket_name, - object_name=f"{self.file_prefix}/{object_name}", + object_name=self._object_path(object_name), + ) + + async def put_bytes( + self, + *, + data: bytes, + object_name: str, + content_type: str, + filename: str | None = None, + ) -> str: + await self.client.put_object( + bucket_name=self.bucket_name, + object_name=self._object_path(object_name), + data=io.BytesIO(data), + length=len(data), + part_size=10 * 1024 * 1024, + content_type=content_type, + metadata={"filename": filename or object_name}, + ) + return object_name + + async def copy(self, *, source_object_name: str, target_object_name: str) -> str: + await self.client.copy_object( + bucket_name=self.bucket_name, + object_name=self._object_path(target_object_name), + source=CopySource( + self.bucket_name, + self._object_path(source_object_name), + ), ) + return target_object_name image_ext_content_type_map = { "apng": ["image/apng"], diff --git a/app/infra/nats.py b/app/infra/nats.py index 558aa31..d2d2454 100644 --- a/app/infra/nats.py +++ b/app/infra/nats.py @@ -15,6 +15,9 @@ class NatsSubjects(Enum): USER_SIGNUP = "user.signup" USER_LOGIN = "user.login" USER_LOGOUT = "user.logout" + STAFF_UPLOAD_REQUEST_CREATED = "staff.upload_request.created" + STAFF_UPLOAD_REQUEST_APPROVED = "staff.upload_request.approved" + STAFF_UPLOAD_REQUEST_REJECTED = "staff.upload_request.rejected" class NatsClient: _nc: Optional[NATS] = None diff --git a/app/router/staff/__init__.py b/app/router/staff/__init__.py index eff98ab..e17b02c 100644 --- a/app/router/staff/__init__.py +++ b/app/router/staff/__init__.py @@ -1,6 +1,10 @@ -from app.router.staff.drive import router as staff_drive_router from fastapi import APIRouter +from app.router.staff.drive import router as staff_drive_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 = APIRouter(prefix="/stuff", tags=["stuff"]) router.include_router(staff_drive_router) +router.include_router(staff_notifications_router) +router.include_router(staff_uploads_router) diff --git a/app/router/staff/uploads.py b/app/router/staff/uploads.py index 6444060..cac4277 100644 --- a/app/router/staff/uploads.py +++ b/app/router/staff/uploads.py @@ -2,6 +2,7 @@ from uuid import UUID from fastapi import APIRouter, Depends, Query +from fastapi.responses import Response from app.container import Container, get_container from app.deps.staff_auth import ( @@ -14,6 +15,7 @@ ) from app.schema.response.staff.uploads import ( UploadRequestListResponse, + UploadRequestPhotoListResponse, UploadRequestSchema, ) from db.generated.models import StaffUser, UploadRequestStatus @@ -53,6 +55,48 @@ async def list_upload_requests( ) +@router.get("/{request_id}", response_model=UploadRequestSchema) +async def get_upload_request( + request_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> UploadRequestSchema: + upload_request = await container.upload_requests_service.get_request_details( + request_id=request_id, + current_staff_user=current_staff_user, + ) + return UploadRequestSchema.from_models(upload_request.request, upload_request.photos) + + +@router.get("/{request_id}/photos", response_model=UploadRequestPhotoListResponse) +async def list_upload_request_photos( + request_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> UploadRequestPhotoListResponse: + upload_request = await container.upload_requests_service.get_request_details( + request_id=request_id, + current_staff_user=current_staff_user, + ) + return UploadRequestPhotoListResponse.from_models(upload_request.photos) + + +@router.get("/{request_id}/photos/{photo_id}/preview") +async def preview_upload_request_photo( + request_id: UUID, + photo_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> Response: + preview = await container.upload_requests_service.get_request_photo_preview( + request_id=request_id, + photo_id=photo_id, + current_staff_user=current_staff_user, + ) + headers = {"Content-Disposition": f'inline; filename="{preview.file_name}"'} + return Response(content=preview.data, media_type=preview.content_type, headers=headers) + + @router.post("/{request_id}/approve", response_model=UploadRequestSchema) async def approve_upload_request( request_id: UUID, diff --git a/app/schema/dto/staff/uploads.py b/app/schema/dto/staff/uploads.py index d64670d..c8b91da 100644 --- a/app/schema/dto/staff/uploads.py +++ b/app/schema/dto/staff/uploads.py @@ -5,9 +5,6 @@ @dataclass(frozen=True) class UploadPhotoInput: drive_file_id: str - file_name: str - mime_type: str - size_bytes: int taken_at: datetime | None day_number: int | None visibility: str diff --git a/app/schema/request/staff/uploads.py b/app/schema/request/staff/uploads.py index d83f7cd..191796d 100644 --- a/app/schema/request/staff/uploads.py +++ b/app/schema/request/staff/uploads.py @@ -1,48 +1,27 @@ from datetime import datetime -from pathlib import PurePath -from typing import ClassVar -from pydantic import BaseModel, Field, field_validator, model_validator +from pydantic import BaseModel, Field, field_validator from uuid import UUID from app.schema.dto.staff.uploads import UploadPhotoInput MAX_UPLOAD_BATCH_SIZE = 20 -MAX_UPLOAD_PHOTO_SIZE_BYTES = 20 * 1024 * 1024 -ALLOWED_IMAGE_MIME_TYPES: dict[str, tuple[str, ...]] = { - "image/jpeg": (".jpg", ".jpeg"), - "image/png": (".png",), - "image/webp": (".webp",), -} class CreateUploadRequestPhotoRequest(BaseModel): drive_file_id: str = Field(min_length=1, max_length=255) - file_name: str = Field(min_length=1, max_length=255) - mime_type: str - size_bytes: int = Field(gt=0, le=MAX_UPLOAD_PHOTO_SIZE_BYTES) taken_at: datetime | None = None day_number: int | None = None visibility: str = "private" - _allowed_mime_types: ClassVar[dict[str, tuple[str, ...]]] = ALLOWED_IMAGE_MIME_TYPES - - @field_validator("drive_file_id", "file_name", mode="before") + @field_validator("drive_file_id", mode="before") @classmethod def _strip_required_text(cls, value: object) -> object: if isinstance(value, str): return value.strip() return value - @field_validator("mime_type") - @classmethod - def _validate_mime_type(cls, value: str) -> str: - normalized_value = value.strip().lower() - if normalized_value not in cls._allowed_mime_types: - raise ValueError("Unsupported image format") - return normalized_value - @field_validator("visibility") @classmethod def _validate_visibility(cls, value: str) -> str: @@ -51,20 +30,9 @@ def _validate_visibility(cls, value: str) -> str: raise ValueError("visibility must be either 'private' or 'public'") return normalized_value - @model_validator(mode="after") - def _validate_file_extension(self) -> "CreateUploadRequestPhotoRequest": - extension = PurePath(self.file_name).suffix.lower() - allowed_extensions = self._allowed_mime_types[self.mime_type] - if extension not in allowed_extensions: - raise ValueError("file_name extension does not match mime_type") - return self - def to_input(self) -> UploadPhotoInput: return UploadPhotoInput( drive_file_id=self.drive_file_id, - file_name=self.file_name, - mime_type=self.mime_type, - size_bytes=self.size_bytes, taken_at=self.taken_at, day_number=self.day_number, visibility=self.visibility, diff --git a/app/schema/response/staff/uploads.py b/app/schema/response/staff/uploads.py index 8e2d01e..6b296cf 100644 --- a/app/schema/response/staff/uploads.py +++ b/app/schema/response/staff/uploads.py @@ -9,9 +9,13 @@ class UploadRequestSchema(BaseModel): class UploadRequestPhotoSchema(BaseModel): id: UUID drive_file_id: str + file_name: str + mime_type: str + size_bytes: int taken_at: datetime | None day_number: int | None visibility: str + status: str created_at: datetime @classmethod @@ -22,9 +26,13 @@ def from_model( return cls( id=photo.id, drive_file_id=photo.drive_file_id, + file_name=photo.file_name, + mime_type=photo.mime_type, + size_bytes=photo.size_bytes, taken_at=photo.taken_at, day_number=photo.day_number, visibility=photo.visibility, + status=photo.status, created_at=photo.created_at, ) @@ -75,3 +83,16 @@ def from_models( for upload_request, photos in items ] ) + + +class UploadRequestPhotoListResponse(BaseModel): + items: list[UploadRequestSchema.UploadRequestPhotoSchema] + + @classmethod + def from_models( + cls, + photos: list[UploadRequestPhoto], + ) -> "UploadRequestPhotoListResponse": + return cls( + items=[UploadRequestSchema.UploadRequestPhotoSchema.from_model(photo) for photo in photos] + ) diff --git a/app/service/staff_drive.py b/app/service/staff_drive.py index a82fc31..4e4019f 100644 --- a/app/service/staff_drive.py +++ b/app/service/staff_drive.py @@ -90,6 +90,19 @@ async def get_status(self, staff_user_id: uuid.UUID) -> StaffDriveConnection | N provider=self.PROVIDER, ) + async def get_active_connection_or_raise( + self, + staff_user_id: uuid.UUID, + ) -> StaffDriveConnection: + connection = await self.get_status(staff_user_id) + if connection is None: + raise AppException.bad_request("Staff Google Drive is not connected") + return connection + + async def get_access_token_for_staff_user(self, staff_user_id: uuid.UUID) -> str: + connection = await self.get_active_connection_or_raise(staff_user_id) + return self.decrypt(connection.access_token) + async def disconnect(self, staff_user_id: uuid.UUID) -> None: connection = await self.get_status(staff_user_id) if connection is None: diff --git a/app/service/staged_upload_storage.py b/app/service/staged_upload_storage.py new file mode 100644 index 0000000..813fa2b --- /dev/null +++ b/app/service/staged_upload_storage.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +import uuid + +from app.core.exceptions import AppException +from app.infra.minio import Bucket, IMAGES_BUCKET_NAME + + +@dataclass(frozen=True) +class StoredObject: + storage_key: str + content_type: str + file_name: str + + +@dataclass(frozen=True) +class PreviewObject: + data: bytes + content_type: str + file_name: str + + +class StagedUploadStorageService: + def __init__(self) -> None: + self.bucket = Bucket(IMAGES_BUCKET_NAME, "") + + @staticmethod + def build_staging_key( + *, + upload_request_id: uuid.UUID, + photo_id: uuid.UUID, + file_name: str, + ) -> str: + extension = Path(file_name).suffix.lower() + return f"staging/upload-requests/{upload_request_id}/{photo_id}{extension}" + + @staticmethod + def build_final_key( + *, + event_id: uuid.UUID, + photo_id: uuid.UUID, + file_name: str, + ) -> str: + extension = Path(file_name).suffix.lower() + return f"events/{event_id}/{photo_id}{extension}" + + async def store_staging_object( + self, + *, + upload_request_id: uuid.UUID, + photo_id: uuid.UUID, + file_name: str, + content_type: str, + data: bytes, + ) -> StoredObject: + storage_key = self.build_staging_key( + upload_request_id=upload_request_id, + photo_id=photo_id, + file_name=file_name, + ) + await self.bucket.put_bytes( + data=data, + object_name=storage_key, + content_type=content_type, + filename=file_name, + ) + return StoredObject( + storage_key=storage_key, + content_type=content_type, + file_name=file_name, + ) + + async def promote_to_final( + self, + *, + event_id: uuid.UUID, + photo_id: uuid.UUID, + file_name: str, + staging_storage_key: str, + ) -> str: + final_key = self.build_final_key( + event_id=event_id, + photo_id=photo_id, + file_name=file_name, + ) + await self.bucket.copy( + source_object_name=staging_storage_key, + target_object_name=final_key, + ) + return final_key + + async def delete_storage_key(self, storage_key: str) -> None: + try: + await self.bucket.delete(storage_key) + except Exception as exc: + raise AppException.storage_error("Failed to delete staged image from storage") from exc + + async def get_preview(self, storage_key: str) -> PreviewObject: + data, file_name, content_type = await self.bucket.get(storage_key) + return PreviewObject(data=data, file_name=file_name, content_type=content_type) diff --git a/app/service/upload_requests.py b/app/service/upload_requests.py index 6cac238..83732a2 100644 --- a/app/service/upload_requests.py +++ b/app/service/upload_requests.py @@ -1,12 +1,19 @@ from collections.abc import Sequence from dataclasses import dataclass +from collections import defaultdict +import json from typing import Literal import uuid from app.core.exceptions import AppException +from app.core.logger import logger +from app.infra.google_drive import GoogleDriveClient, GoogleDriveFileDownload +from app.infra.nats import NatsClient, NatsSubjects from sqlalchemy.exc import IntegrityError from app.schema.dto.staff.uploads import UploadPhotoInput +from app.service.staged_upload_storage import PreviewObject, StagedUploadStorageService +from app.service.staff_drive import StaffDriveService from app.service.staff_notifications import StaffNotificationsService from db.generated import photos as photo_queries from db.generated import upload_request_photos as upload_request_photo_queries @@ -26,22 +33,23 @@ class UploadRequestDetails: class UploadRequestsService: - _mime_type_extensions = { - "image/jpeg": ".jpg", - "image/png": ".png", - "image/webp": ".webp", - } + _allowed_mime_types = {"image/jpeg", "image/png", "image/webp"} + _max_photo_size_bytes = 20 * 1024 * 1024 def __init__( self, upload_request_querier: upload_request_queries.AsyncQuerier, upload_request_photo_querier: upload_request_photo_queries.AsyncQuerier, photo_querier: photo_queries.AsyncQuerier, + staged_upload_storage: StagedUploadStorageService, + staff_drive_service: StaffDriveService, staff_notifications_service: StaffNotificationsService, ): self.upload_request_querier = upload_request_querier self.upload_request_photo_querier = upload_request_photo_querier self.photo_querier = photo_querier + self.staged_upload_storage = staged_upload_storage + self.staff_drive_service = staff_drive_service self.staff_notifications_service = staff_notifications_service @staticmethod @@ -52,14 +60,6 @@ def _status_value(status: object) -> str: def _role_value(role: object) -> str: return getattr(role, "value", str(role)) - def _build_staging_storage_key( - self, - upload_request_id: uuid.UUID, - photo: UploadPhotoInput, - ) -> str: - extension = self._mime_type_extensions[photo.mime_type] - return f"staging/upload-requests/{upload_request_id}/{uuid.uuid4()}{extension}" - @staticmethod def _raise_integrity_error(exc: IntegrityError) -> None: orig = getattr(exc, "orig", None) @@ -72,21 +72,203 @@ def _raise_integrity_error(exc: IntegrityError) -> None: raise AppException.internal_error("Failed to persist upload request") from exc - async def create_request( - self, - *, - event_id: uuid.UUID, - photos: Sequence[UploadPhotoInput], - requested_by: StaffUser, - ) -> UploadRequestDetails: + def _validate_downloaded_photo(self, downloaded_photo: GoogleDriveFileDownload) -> None: + metadata = downloaded_photo.metadata + if metadata.mime_type not in self._allowed_mime_types: + raise AppException.image_format_error("Unsupported image format from Google Drive") + if metadata.size_bytes <= 0 or metadata.size_bytes > self._max_photo_size_bytes: + raise AppException.bad_request("Google Drive image exceeds maximum allowed size") + + @staticmethod + def _validate_create_request_inputs(photos: Sequence[UploadPhotoInput]) -> None: if not photos: raise AppException.bad_request("At least one photo is required") if len(photos) > 20: raise AppException.bad_request("A batch can contain at most 20 photos") + drive_file_ids = [photo.drive_file_id for photo in photos] if len(drive_file_ids) != len(set(drive_file_ids)): raise AppException.conflict("Duplicate drive_file_id found in upload request batch") + async def _cleanup_created_photos(self, created_photos: Sequence[UploadRequestPhoto]) -> None: + for created_photo in created_photos: + try: + await self.staged_upload_storage.delete_storage_key(created_photo.staging_storage_key) + except Exception: + logger.warning( + "Failed to clean staged object %s after create failure", + created_photo.staging_storage_key, + ) + + async def _cleanup_finalized_objects(self, storage_keys: Sequence[str]) -> None: + for storage_key in storage_keys: + try: + await self.staged_upload_storage.delete_storage_key(storage_key) + except Exception: + logger.warning( + "Failed to clean finalized object %s after approval failure", + storage_key, + ) + + async def _delete_staging_objects_best_effort( + self, + staged_photos: Sequence[UploadRequestPhoto], + ) -> None: + for staged_photo in staged_photos: + try: + await self.staged_upload_storage.delete_storage_key(staged_photo.staging_storage_key) + except Exception as exc: + logger.warning( + "Failed to delete staging object %s: %s", + staged_photo.staging_storage_key, + exc, + ) + + async def _list_request_photos_by_request_ids( + self, + request_ids: Sequence[uuid.UUID], + ) -> dict[uuid.UUID, list[UploadRequestPhoto]]: + photos_by_request_id: dict[uuid.UUID, list[UploadRequestPhoto]] = defaultdict(list) + if not request_ids: + return photos_by_request_id + + async for photo in self.upload_request_photo_querier.list_upload_request_photos_by_upload_request_ids( + upload_request_ids=list(request_ids) + ): + photos_by_request_id[photo.upload_request_id].append(photo) + + return photos_by_request_id + + async def _create_staged_photo( + self, + *, + upload_request_id: uuid.UUID, + photo: UploadPhotoInput, + access_token: str, + ) -> UploadRequestPhoto: + downloaded_photo = await GoogleDriveClient.download_file( + access_token=access_token, + file_id=photo.drive_file_id, + ) + self._validate_downloaded_photo(downloaded_photo) + + stored_object = await self.staged_upload_storage.store_staging_object( + upload_request_id=upload_request_id, + photo_id=uuid.uuid4(), + file_name=downloaded_photo.metadata.name, + content_type=downloaded_photo.metadata.mime_type, + data=downloaded_photo.content, + ) + + try: + created_photo = await self.upload_request_photo_querier.create_upload_request_photo( + upload_request_id=upload_request_id, + drive_file_id=photo.drive_file_id, + file_name=downloaded_photo.metadata.name, + mime_type=downloaded_photo.metadata.mime_type, + size_bytes=downloaded_photo.metadata.size_bytes, + staging_storage_key=stored_object.storage_key, + taken_at=photo.taken_at, + day_number=photo.day_number, + visibility=photo.visibility, + status="staged", + ) + except IntegrityError: + try: + await self.staged_upload_storage.delete_storage_key(stored_object.storage_key) + except Exception: + logger.warning( + "Failed to clean staged object %s after photo insert conflict", + stored_object.storage_key, + ) + raise + + if created_photo is None: + try: + await self.staged_upload_storage.delete_storage_key(stored_object.storage_key) + except Exception: + logger.warning( + "Failed to clean staged object %s after empty photo insert result", + stored_object.storage_key, + ) + raise AppException.internal_error("Failed to create staged upload photo") + + return created_photo + + def _ensure_request_access( + self, + *, + current_staff_user: StaffUser, + upload_request: UploadRequest, + ) -> None: + if upload_request.requested_by == current_staff_user.id: + return + if self._role_value(current_staff_user.role) == StaffRole.MULTI_TEAM_LEAD.value: + return + raise AppException.forbidden("You are not allowed to access this upload request") + + async def _publish_event( + self, + *, + subject: NatsSubjects, + payload: dict[str, object], + ) -> None: + try: + await NatsClient.publish(subject, json.dumps(payload).encode("utf-8")) + except Exception as exc: + logger.warning("Failed to publish upload request event %s: %s", subject.value, exc) + + async def get_request_details( + self, + *, + request_id: uuid.UUID, + current_staff_user: StaffUser, + ) -> UploadRequestDetails: + upload_request = await self.upload_request_querier.get_upload_request_by_id(id=request_id) + if upload_request is None: + raise AppException.not_found("Upload request not found") + self._ensure_request_access( + current_staff_user=current_staff_user, + upload_request=upload_request, + ) + return UploadRequestDetails( + request=upload_request, + photos=await self.list_request_photos(upload_request.id), + ) + + async def get_request_photo_preview( + self, + *, + request_id: uuid.UUID, + photo_id: uuid.UUID, + current_staff_user: StaffUser, + ) -> PreviewObject: + upload_request = await self.upload_request_querier.get_upload_request_by_id(id=request_id) + if upload_request is None: + raise AppException.not_found("Upload request not found") + self._ensure_request_access( + current_staff_user=current_staff_user, + upload_request=upload_request, + ) + photo = await self.upload_request_photo_querier.get_upload_request_photo_by_id(id=photo_id) + if photo is None or photo.upload_request_id != request_id: + raise AppException.not_found("Upload request photo not found") + storage_key = photo.final_storage_key or photo.staging_storage_key + return await self.staged_upload_storage.get_preview(storage_key) + + async def create_request( + self, + *, + event_id: uuid.UUID, + photos: Sequence[UploadPhotoInput], + requested_by: StaffUser, + ) -> UploadRequestDetails: + self._validate_create_request_inputs(photos) + + access_token = await self.staff_drive_service.get_access_token_for_staff_user( + requested_by.id + ) + try: upload_request = await self.upload_request_querier.create_upload_request( event_id=event_id, @@ -102,19 +284,29 @@ async def create_request( created_photos: list[UploadRequestPhoto] = [] try: for photo in photos: - created_photo = await self.upload_request_photo_querier.create_upload_request_photo( - upload_request_id=upload_request.id, - drive_file_id=photo.drive_file_id, - staging_storage_key=self._build_staging_storage_key(upload_request.id, photo), - taken_at=photo.taken_at, - day_number=photo.day_number, - visibility=photo.visibility, + created_photos.append( + await self._create_staged_photo( + upload_request_id=upload_request.id, + photo=photo, + access_token=access_token, + ) ) - if created_photo is None: - raise AppException.internal_error("Failed to create staged upload photo") - created_photos.append(created_photo) except IntegrityError as exc: + await self._cleanup_created_photos(created_photos) self._raise_integrity_error(exc) + except Exception: + await self._cleanup_created_photos(created_photos) + raise + + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_REQUEST_CREATED, + payload={ + "upload_request_id": str(upload_request.id), + "event_id": str(upload_request.event_id), + "requested_by": str(requested_by.id), + "photo_count": upload_request.photo_count, + }, + ) return UploadRequestDetails(request=upload_request, photos=created_photos) @@ -130,15 +322,23 @@ async def list_requests( requested_by = current_staff_user.id if scope == "my" else None - requests: list[UploadRequestDetails] = [] + request_rows: list[UploadRequest] = [] async for upload_request in self.upload_request_querier.list_upload_requests( requested_by=requested_by, status=status, ): + request_rows.append(upload_request) + + photos_by_request_id = await self._list_request_photos_by_request_ids( + [upload_request.id for upload_request in request_rows] + ) + + requests: list[UploadRequestDetails] = [] + for upload_request in request_rows: requests.append( UploadRequestDetails( request=upload_request, - photos=await self.list_request_photos(upload_request.id), + photos=photos_by_request_id.get(upload_request.id, []), ) ) return requests @@ -170,40 +370,68 @@ async def approve_request( if not staged_photos: raise AppException.bad_request("No staged photos found for this upload request") - upload_request = await self.upload_request_querier.approve_upload_request( - id=request_id, - approved_by=approved_by.id, - ) - if upload_request is None: - raise AppException.internal_error("Failed to approve upload request") + finalized_storage_keys: list[str] = [] + try: + for staged_photo in staged_photos: + final_storage_key = await self.staged_upload_storage.promote_to_final( + event_id=existing.event_id, + photo_id=staged_photo.id, + file_name=staged_photo.file_name, + staging_storage_key=staged_photo.staging_storage_key, + ) + finalized_storage_keys.append(final_storage_key) + created_photo = await self.photo_querier.create_photo( + event_id=existing.event_id, + storage_key=final_storage_key, + taken_at=staged_photo.taken_at, + day_number=staged_photo.day_number, + visibility=staged_photo.visibility, + ) + if created_photo is None: + raise AppException.internal_error("Failed to finalize staged photo") + updated_photo = await self.upload_request_photo_querier.update_upload_request_photo_approval( + id=staged_photo.id, + status="approved", + final_storage_key=final_storage_key, + ) + if updated_photo is None: + raise AppException.internal_error("Failed to update staged photo approval state") - for staged_photo in staged_photos: - created_photo = await self.photo_querier.create_photo( - event_id=upload_request.event_id, - storage_key=staged_photo.staging_storage_key, - taken_at=staged_photo.taken_at, - day_number=staged_photo.day_number, - visibility=staged_photo.visibility, + upload_request = await self.upload_request_querier.approve_upload_request( + id=request_id, + approved_by=approved_by.id, ) - if created_photo is None: - raise AppException.internal_error("Failed to finalize staged photo") - - await self.upload_request_photo_querier.delete_upload_request_photos_by_upload_request_id( - upload_request_id=request_id - ) + if upload_request is None: + raise AppException.internal_error("Failed to approve upload request") + await self.staff_notifications_service.create_notification( + staff_user_id=upload_request.requested_by, + type="upload_request_approved", + payload={ + "upload_request_id": str(upload_request.id), + "event_id": str(upload_request.event_id), + "photo_count": upload_request.photo_count, + "approved_by": str(approved_by.id), + "status": "approved", + }, + ) + except Exception: + await self._cleanup_finalized_objects(finalized_storage_keys) + raise - await self.staff_notifications_service.create_notification( - staff_user_id=upload_request.requested_by, - type="upload_request_approved", + await self._delete_staging_objects_best_effort(staged_photos) + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_REQUEST_APPROVED, payload={ "upload_request_id": str(upload_request.id), "event_id": str(upload_request.event_id), - "photo_count": upload_request.photo_count, "approved_by": str(approved_by.id), - "status": "approved", + "photo_count": upload_request.photo_count, }, ) - return UploadRequestDetails(request=upload_request, photos=[]) + return UploadRequestDetails( + request=upload_request, + photos=await self.list_request_photos(request_id), + ) async def reject_request( self, @@ -226,9 +454,13 @@ async def reject_request( if upload_request is None: raise AppException.internal_error("Failed to reject upload request") - await self.upload_request_photo_querier.delete_upload_request_photos_by_upload_request_id( - upload_request_id=request_id - ) + staged_photos = await self.list_request_photos(request_id) + rejected_photos: list[UploadRequestPhoto] = [] + async for staged_photo in self.upload_request_photo_querier.update_upload_request_photo_status_by_upload_request_id( + upload_request_id=request_id, + status="rejected", + ): + rejected_photos.append(staged_photo) await self.staff_notifications_service.create_notification( staff_user_id=upload_request.requested_by, @@ -242,4 +474,15 @@ async def reject_request( "reason": reason, }, ) - return UploadRequestDetails(request=upload_request, photos=[]) + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_REQUEST_REJECTED, + payload={ + "upload_request_id": str(upload_request.id), + "event_id": str(upload_request.event_id), + "approved_by": str(approved_by.id), + "photo_count": upload_request.photo_count, + "reason": reason, + }, + ) + await self._delete_staging_objects_best_effort(staged_photos) + return UploadRequestDetails(request=upload_request, photos=rejected_photos) diff --git a/db/generated/models.py b/db/generated/models.py index 7dca360..07b95b8 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -182,10 +182,15 @@ class UploadRequestPhoto: id: uuid.UUID upload_request_id: uuid.UUID drive_file_id: str + file_name: str + mime_type: str + size_bytes: int staging_storage_key: str + final_storage_key: Optional[str] taken_at: Optional[datetime.datetime] day_number: Optional[int] visibility: str + status: str created_at: datetime.datetime @@ -199,9 +204,6 @@ class User: display_name: Optional[str] face_embedding: Optional[Any] deleted_at: Optional[datetime.datetime] - display_name: Optional[str] - face_embedding: Optional[Any] - deleted_at: Optional[datetime.datetime] @dataclasses.dataclass() diff --git a/db/generated/upload_request_photos.py b/db/generated/upload_request_photos.py index 1b679ac..a329598 100644 --- a/db/generated/upload_request_photos.py +++ b/db/generated/upload_request_photos.py @@ -13,21 +13,30 @@ INSERT INTO upload_request_photos ( upload_request_id, drive_file_id, + file_name, + mime_type, + size_bytes, staging_storage_key, taken_at, day_number, - visibility + visibility, + status ) VALUES ( - :p1, :p2, :p3, :p4, :p5, :p6 + :p1, :p2, :p3, :p4, :p5, :p6, :p7, :p8, :p9, :p10 ) RETURNING id, upload_request_id, drive_file_id, + file_name, + mime_type, + size_bytes, staging_storage_key, + final_storage_key, taken_at, day_number, visibility, + status, created_at """ @@ -37,10 +46,15 @@ id, upload_request_id, drive_file_id, + file_name, + mime_type, + size_bytes, staging_storage_key, + final_storage_key, taken_at, day_number, visibility, + status, created_at FROM upload_request_photos WHERE upload_request_id = :p1 @@ -48,6 +62,90 @@ """ +LIST_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_IDS = """-- name: list_upload_request_photos_by_upload_request_ids \\:many +SELECT + id, + upload_request_id, + drive_file_id, + file_name, + mime_type, + size_bytes, + staging_storage_key, + final_storage_key, + taken_at, + day_number, + visibility, + status, + created_at +FROM upload_request_photos +WHERE upload_request_id = ANY(:p1) +ORDER BY created_at ASC +""" + + +GET_UPLOAD_REQUEST_PHOTO_BY_ID = """-- name: get_upload_request_photo_by_id \\:one +SELECT + id, + upload_request_id, + drive_file_id, + file_name, + mime_type, + size_bytes, + staging_storage_key, + final_storage_key, + taken_at, + day_number, + visibility, + status, + created_at +FROM upload_request_photos +WHERE id = :p1 +""" + + +UPDATE_UPLOAD_REQUEST_PHOTO_APPROVAL = """-- name: update_upload_request_photo_approval \\:one +UPDATE upload_request_photos +SET status = :p2, + final_storage_key = :p3 +WHERE id = :p1 +RETURNING + id, + upload_request_id, + drive_file_id, + file_name, + mime_type, + size_bytes, + staging_storage_key, + final_storage_key, + taken_at, + day_number, + visibility, + status, + created_at +""" + + +UPDATE_UPLOAD_REQUEST_PHOTO_STATUS_BY_UPLOAD_REQUEST_ID = """-- name: update_upload_request_photo_status_by_upload_request_id \\:many +UPDATE upload_request_photos +SET status = :p2 +WHERE upload_request_id = :p1 +RETURNING + id, + upload_request_id, + drive_file_id, + file_name, + mime_type, + size_bytes, + staging_storage_key, + final_storage_key, + taken_at, + day_number, + visibility, + status, + created_at +""" + + DELETE_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_ID = """-- name: delete_upload_request_photos_by_upload_request_id \\:exec DELETE FROM upload_request_photos WHERE upload_request_id = :p1 @@ -63,10 +161,14 @@ async def create_upload_request_photo( *, upload_request_id: uuid.UUID, drive_file_id: str, + file_name: str, + mime_type: str, + size_bytes: int, staging_storage_key: str, taken_at: datetime.datetime | None, day_number: int | None, visibility: str, + status: str, ) -> Optional[models.UploadRequestPhoto]: row = ( await self._conn.execute( @@ -74,10 +176,14 @@ async def create_upload_request_photo( { "p1": upload_request_id, "p2": drive_file_id, - "p3": staging_storage_key, - "p4": taken_at, - "p5": day_number, - "p6": visibility, + "p3": file_name, + "p4": mime_type, + "p5": size_bytes, + "p6": staging_storage_key, + "p7": taken_at, + "p8": day_number, + "p9": visibility, + "p10": status, }, ) ).first() @@ -97,6 +203,63 @@ async def list_upload_request_photos_by_upload_request_id( async for row in result: yield _row_to_upload_request_photo(row) + async def list_upload_request_photos_by_upload_request_ids( + self, + *, + upload_request_ids: list[uuid.UUID], + ) -> AsyncIterator[models.UploadRequestPhoto]: + statement = sqlalchemy.text(LIST_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_IDS).bindparams( + sqlalchemy.bindparam("p1", type_=sqlalchemy.ARRAY(sqlalchemy.Uuid)) + ) + result = await self._conn.stream(statement, {"p1": upload_request_ids}) + async for row in result: + yield _row_to_upload_request_photo(row) + + async def get_upload_request_photo_by_id( + self, + *, + id: uuid.UUID, + ) -> Optional[models.UploadRequestPhoto]: + row = ( + await self._conn.execute( + sqlalchemy.text(GET_UPLOAD_REQUEST_PHOTO_BY_ID), + {"p1": id}, + ) + ).first() + if row is None: + return None + return _row_to_upload_request_photo(row) + + async def update_upload_request_photo_approval( + self, + *, + id: uuid.UUID, + status: str, + final_storage_key: str | None, + ) -> Optional[models.UploadRequestPhoto]: + row = ( + await self._conn.execute( + sqlalchemy.text(UPDATE_UPLOAD_REQUEST_PHOTO_APPROVAL), + {"p1": id, "p2": status, "p3": final_storage_key}, + ) + ).first() + if row is None: + return None + return _row_to_upload_request_photo(row) + + async def update_upload_request_photo_status_by_upload_request_id( + self, + *, + upload_request_id: uuid.UUID, + status: str, + ) -> AsyncIterator[models.UploadRequestPhoto]: + result = await self._conn.stream( + sqlalchemy.text(UPDATE_UPLOAD_REQUEST_PHOTO_STATUS_BY_UPLOAD_REQUEST_ID), + {"p1": upload_request_id, "p2": status}, + ) + async for row in result: + yield _row_to_upload_request_photo(row) + async def delete_upload_request_photos_by_upload_request_id( self, *, @@ -115,9 +278,14 @@ def _row_to_upload_request_photo( id=row[0], upload_request_id=row[1], drive_file_id=row[2], - staging_storage_key=row[3], - taken_at=row[4], - day_number=row[5], - visibility=row[6], - created_at=row[7], + file_name=row[3], + mime_type=row[4], + size_bytes=row[5], + staging_storage_key=row[6], + final_storage_key=row[7], + taken_at=row[8], + day_number=row[9], + visibility=row[10], + status=row[11], + created_at=row[12], ) diff --git a/db/queries/staff_notifications.sql b/db/queries/staff_notifications.sql new file mode 100644 index 0000000..74070b8 --- /dev/null +++ b/db/queries/staff_notifications.sql @@ -0,0 +1,23 @@ +-- name: CreateStaffNotification :one +INSERT INTO staff_notifications ( + staff_user_id, + type, + payload +) VALUES ( + $1, $2, $3 +) +RETURNING *; + +-- name: ListStaffNotificationsByStaffUserID :many +SELECT * +FROM staff_notifications +WHERE staff_user_id = $1 +ORDER BY created_at DESC; + +-- name: MarkStaffNotificationAsRead :one +UPDATE staff_notifications +SET read_at = NOW() +WHERE id = $1 + AND staff_user_id = $2 + AND read_at IS NULL +RETURNING *; diff --git a/db/queries/upload_request_photos.sql b/db/queries/upload_request_photos.sql new file mode 100644 index 0000000..bab46b5 --- /dev/null +++ b/db/queries/upload_request_photos.sql @@ -0,0 +1,50 @@ +-- name: CreateUploadRequestPhoto :one +INSERT INTO upload_request_photos ( + upload_request_id, + drive_file_id, + file_name, + mime_type, + size_bytes, + staging_storage_key, + taken_at, + day_number, + visibility, + status +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 +) +RETURNING *; + +-- name: ListUploadRequestPhotosByUploadRequestID :many +SELECT * +FROM upload_request_photos +WHERE upload_request_id = $1 +ORDER BY created_at ASC; + +-- name: ListUploadRequestPhotosByUploadRequestIDs :many +SELECT * +FROM upload_request_photos +WHERE upload_request_id = ANY($1::uuid[]) +ORDER BY created_at ASC; + +-- name: GetUploadRequestPhotoByID :one +SELECT * +FROM upload_request_photos +WHERE id = $1; + +-- name: UpdateUploadRequestPhotoApproval :one +UPDATE upload_request_photos +SET status = $2, + final_storage_key = $3 +WHERE id = $1 +RETURNING *; + +-- name: UpdateUploadRequestPhotoStatusByUploadRequestID :many +UPDATE upload_request_photos +SET status = $2 +WHERE upload_request_id = $1 +RETURNING *; + +-- name: DeleteUploadRequestPhotosByUploadRequestID :exec +DELETE FROM upload_request_photos +WHERE upload_request_id = $1; diff --git a/migrations/sql/down/add-staff-upload-review-base.sql b/migrations/sql/down/add-staff-upload-review-base.sql new file mode 100644 index 0000000..6c2ae41 --- /dev/null +++ b/migrations/sql/down/add-staff-upload-review-base.sql @@ -0,0 +1,19 @@ +DROP TABLE IF EXISTS upload_request_photos; + +ALTER TABLE upload_requests + DROP COLUMN IF EXISTS rejection_reason, + DROP COLUMN IF EXISTS photo_count; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM upload_requests + WHERE drive_file_id IS NULL + ) THEN + ALTER TABLE upload_requests + ALTER COLUMN drive_file_id SET NOT NULL; + END IF; +END $$; + +DROP TABLE IF EXISTS staff_notifications; diff --git a/migrations/sql/up/add-staff-upload-review-base.sql b/migrations/sql/up/add-staff-upload-review-base.sql new file mode 100644 index 0000000..ee45a2a --- /dev/null +++ b/migrations/sql/up/add-staff-upload-review-base.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS staff_notifications ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + staff_user_id UUID NOT NULL REFERENCES staff_users(id) ON DELETE CASCADE, + type VARCHAR(64) NOT NULL, + payload JSONB NOT NULL, + read_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_staff_notifications_staff_user_id +ON staff_notifications(staff_user_id); + +CREATE INDEX IF NOT EXISTS idx_staff_notifications_read_at +ON staff_notifications(read_at); + +ALTER TABLE upload_requests + ALTER COLUMN drive_file_id DROP NOT NULL; + +ALTER TABLE upload_requests + ADD COLUMN IF NOT EXISTS photo_count INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS rejection_reason TEXT; + +CREATE TABLE IF NOT EXISTS upload_request_photos ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + upload_request_id UUID NOT NULL REFERENCES upload_requests(id) ON DELETE CASCADE, + drive_file_id TEXT NOT NULL, + file_name VARCHAR(255) NOT NULL DEFAULT 'unknown', + mime_type VARCHAR(128) NOT NULL DEFAULT 'application/octet-stream', + size_bytes BIGINT NOT NULL DEFAULT 0, + staging_storage_key TEXT NOT NULL, + final_storage_key TEXT, + taken_at TIMESTAMPTZ, + day_number INT, + visibility VARCHAR(32) NOT NULL DEFAULT 'private', + status VARCHAR(32) NOT NULL DEFAULT 'staged', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(upload_request_id, drive_file_id) +); + +CREATE INDEX IF NOT EXISTS idx_upload_request_photos_upload_request_id +ON upload_request_photos(upload_request_id); + +CREATE INDEX IF NOT EXISTS idx_upload_request_photos_status +ON upload_request_photos(status); diff --git a/migrations/versions/c3b8d0f1e2a4_add_staff_upload_review_base.py b/migrations/versions/c3b8d0f1e2a4_add_staff_upload_review_base.py new file mode 100644 index 0000000..421a4ad --- /dev/null +++ b/migrations/versions/c3b8d0f1e2a4_add_staff_upload_review_base.py @@ -0,0 +1,25 @@ +"""add_staff_upload_review_base + +Revision ID: c3b8d0f1e2a4 +Revises: 5ead72a95638 +Create Date: 2026-03-19 12:00:00.000000 + +""" + +from typing import Sequence, Union + +from migrations.helper import run_sql_down, run_sql_up + + +revision: str = "c3b8d0f1e2a4" +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: + run_sql_up("add-staff-upload-review-base") + + +def downgrade() -> None: + run_sql_down("add-staff-upload-review-base") From 7e8140070ae1bd9d881a82ed8212638f7ab79ab4 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Thu, 19 Mar 2026 13:59:47 +0100 Subject: [PATCH 02/26] clean up generated files and alembic metadata --- db/generated/devices.py | 2 +- migrations/versions/5ead72a95638_merge_alembic_heads.py | 4 ++-- .../989510311240_replace_staff_discord_with_password.py | 6 ------ migrations/versions/e171e4e2247d_init_extension.py | 4 +--- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/db/generated/devices.py b/db/generated/devices.py index 2df5e9b..d1b5b11 100644 --- a/db/generated/devices.py +++ b/db/generated/devices.py @@ -13,7 +13,7 @@ COUNT__USER__DEVICES = """-- name: count__user__devices \\:one -SELECT COUNT(*) +SELECT COUNT(*) FROM user_devices WHERE user_id = :p1 """ diff --git a/migrations/versions/5ead72a95638_merge_alembic_heads.py b/migrations/versions/5ead72a95638_merge_alembic_heads.py index 2d22d7b..4030d98 100644 --- a/migrations/versions/5ead72a95638_merge_alembic_heads.py +++ b/migrations/versions/5ead72a95638_merge_alembic_heads.py @@ -1,7 +1,7 @@ """merge alembic heads Revision ID: 5ead72a95638 -Revises: a4b8c2d9e3f1, b2e532644368 +Revises: eed44c193b3d Create Date: 2026-03-15 14:24:04.545981 """ @@ -9,7 +9,7 @@ # revision identifiers, used by Alembic. revision: str = '5ead72a95638' -down_revision: Union[str, Sequence[str], None] = ('a4b8c2d9e3f1', 'b2e532644368') +down_revision: Union[str, Sequence[str], None] = 'eed44c193b3d' branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/migrations/versions/989510311240_replace_staff_discord_with_password.py b/migrations/versions/989510311240_replace_staff_discord_with_password.py index c495b43..c269361 100644 --- a/migrations/versions/989510311240_replace_staff_discord_with_password.py +++ b/migrations/versions/989510311240_replace_staff_discord_with_password.py @@ -9,9 +9,6 @@ from migrations.helper import run_sql_down, run_sql_up - - - # revision identifiers, used by Alembic. revision: str = '989510311240' down_revision: Union[str, Sequence[str], None] = '8e9b7a6c4d11' @@ -21,9 +18,6 @@ def upgrade() -> None: run_sql_up("replace-staff-discord-with-password") - - - def downgrade() -> None: diff --git a/migrations/versions/e171e4e2247d_init_extension.py b/migrations/versions/e171e4e2247d_init_extension.py index abd2971..8031f26 100644 --- a/migrations/versions/e171e4e2247d_init_extension.py +++ b/migrations/versions/e171e4e2247d_init_extension.py @@ -1,7 +1,7 @@ """init_extension Revision ID: e171e4e2247d -Revises: +Revises: Create Date: 2026-02-28 13:58:27.732494 """ @@ -20,7 +20,5 @@ def upgrade() -> None: run_sql_up("init_extension") - - def downgrade() -> None: run_sql_down("init_extension") From 71303d1360aa2608947bbd134896c3cd10b086e5 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Thu, 19 Mar 2026 19:00:56 +0100 Subject: [PATCH 03/26] improve google drive callback flow --- .env.example | 2 +- app/router/staff/drive.py | 40 ++++++++++++++++-- app/service/staff_drive.py | 83 +++++++++++++++++++++++++++++++------- 3 files changed, 105 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index edc9eca..7642a0a 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,6 @@ totp_issuer=MultiAI GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= -GOOGLE_REDIRECT_URI=http://localhost:8000/staff/drive/callback +GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/stuff/drive/callback GOOGLE_OAUTH_SCOPES=https://www.googleapis.com/auth/drive.readonly openid email profile FACE_ENCRYPTION_KEY=hkbribvfirirbvivbibvib \ No newline at end of file diff --git a/app/router/staff/drive.py b/app/router/staff/drive.py index 72daaef..2f8ede8 100644 --- a/app/router/staff/drive.py +++ b/app/router/staff/drive.py @@ -1,4 +1,5 @@ -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import RedirectResponse from app.container import Container, get_container from app.core.exceptions import AppException @@ -17,11 +18,13 @@ @router.get("/connect", response_model=GoogleDriveConnectResponse) async def connect_google_drive( + redirect_url: str | None = Query(default=None), current_staff_user: StaffUser = Depends(get_current_staff_user), container: Container = Depends(get_container), ) -> GoogleDriveConnectResponse: authorization_url, state = await container.staff_drive_service.create_connect_url( - current_staff_user + current_staff_user, + redirect_url=redirect_url, ) return GoogleDriveConnectResponse(authorization_url=authorization_url, state=state) @@ -32,11 +35,40 @@ async def google_drive_callback( state: str = Query(...), error: str | None = Query(default=None), container: Container = Depends(get_container), -) -> GoogleDriveCallbackResponse: +) -> GoogleDriveCallbackResponse | RedirectResponse: + redirect_url = await container.staff_drive_service.get_callback_redirect_url(state) if error is not None: + if redirect_url is not None: + return RedirectResponse( + container.staff_drive_service.build_frontend_callback_url( + redirect_url, + status="error", + error=error, + ) + ) raise AppException.bad_request(f"Google OAuth error: {error}") - connection = await container.staff_drive_service.handle_callback(code, state) + try: + connection, redirect_url = await container.staff_drive_service.handle_callback(code, state) + except HTTPException as exc: + if redirect_url is not None: + return RedirectResponse( + container.staff_drive_service.build_frontend_callback_url( + redirect_url, + status="error", + error=str(exc.detail), + ) + ) + raise + + if redirect_url is not None: + return RedirectResponse( + container.staff_drive_service.build_frontend_callback_url( + redirect_url, + status="success", + google_email=connection.google_email, + ) + ) return GoogleDriveCallbackResponse( message="Google Drive connected successfully", google_email=connection.google_email, diff --git a/app/service/staff_drive.py b/app/service/staff_drive.py index 4e4019f..d7485c0 100644 --- a/app/service/staff_drive.py +++ b/app/service/staff_drive.py @@ -2,6 +2,7 @@ import hashlib import json import secrets +import urllib.parse import uuid from cryptography.fernet import Fernet, InvalidToken @@ -30,17 +31,42 @@ def __init__( self.drive_connection_querier = drive_connection_querier self.redis = redis - async def create_connect_url(self, staff_user: StaffUser) -> tuple[str, str]: + async def create_connect_url( + self, + staff_user: StaffUser, + redirect_url: str | None = None, + ) -> tuple[str, str]: state = secrets.token_urlsafe(32) + state_payload: dict[str, str] = {"staff_user_id": str(staff_user.id)} + if redirect_url is not None: + state_payload["redirect_url"] = self._validate_redirect_url(redirect_url) + await self.redis.set( self.STATE_PREFIX.format(state=state), - json.dumps({"staff_user_id": str(staff_user.id)}), + json.dumps(state_payload), expire=self.STATE_TTL_SECONDS, nx=True, ) return GoogleDriveClient.build_consent_url(state), state - async def handle_callback(self, code: str, state: str) -> StaffDriveConnection: + async def get_callback_redirect_url(self, state: str) -> str | None: + state_payload = await self.redis.get(self.STATE_PREFIX.format(state=state)) + if state_payload is None: + return None + try: + payload = json.loads(state_payload) + except json.JSONDecodeError: + return None + redirect_url = payload.get("redirect_url") + if isinstance(redirect_url, str) and redirect_url: + return redirect_url + return None + + async def handle_callback( + self, + code: str, + state: str, + ) -> tuple[StaffDriveConnection, str | None]: state_key = self.STATE_PREFIX.format(state=state) state_payload = await self.redis.get(state_key) if state_payload is None: @@ -49,10 +75,15 @@ async def handle_callback(self, code: str, state: str) -> StaffDriveConnection: await self.redis.delete(state_key) try: - staff_user_id = uuid.UUID(json.loads(state_payload)["staff_user_id"]) + payload = json.loads(state_payload) + staff_user_id = uuid.UUID(payload["staff_user_id"]) except (KeyError, ValueError, json.JSONDecodeError) as exc: raise AppException.bad_request("Invalid OAuth state payload") from exc + redirect_url = payload.get("redirect_url") + if redirect_url is not None and not isinstance(redirect_url, str): + raise AppException.bad_request("Invalid OAuth redirect URL") + staff_user = await self.staff_user_querier.get_staff_user_by_id(id=staff_user_id) if staff_user is None: raise AppException.not_found("Staff user not found") @@ -67,22 +98,20 @@ async def handle_callback(self, code: str, state: str) -> StaffDriveConnection: connection = await self.drive_connection_querier.upsert_staff_drive_connection( arg=drive_queries.UpsertStaffDriveConnectionParams( - staff_user_id=staff_user.id, - provider=self.PROVIDER, - google_email=user_info.email, - google_account_id=user_info.id, - access_token=encrypted_access_token, - refresh_token=encrypted_refresh_token, - token_expires_at=token.expires_at, - scopes=token.scope, - + staff_user_id=staff_user.id, + provider=self.PROVIDER, + google_email=user_info.email, + google_account_id=user_info.id, + access_token=encrypted_access_token, + refresh_token=encrypted_refresh_token, + token_expires_at=token.expires_at, + scopes=token.scope, ) - ) if connection is None: raise AppException.internal_error("Failed to save Google Drive connection") - return connection + return connection, redirect_url async def get_status(self, staff_user_id: uuid.UUID) -> StaffDriveConnection | None: return await self.drive_connection_querier.get_active_staff_drive_connection_by_staff_user_id( @@ -125,3 +154,27 @@ def decrypt(self, encrypted_value: str) -> str: def _fernet(self) -> Fernet: digest = hashlib.sha256(settings.encryption_key.encode("utf-8")).digest() return Fernet(base64.urlsafe_b64encode(digest)) + + @staticmethod + def _validate_redirect_url(redirect_url: str) -> str: + parsed = urllib.parse.urlparse(redirect_url) + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise AppException.bad_request("Invalid redirect URL") + return redirect_url + + @staticmethod + def build_frontend_callback_url( + redirect_url: str, + *, + status: str, + google_email: str | None = None, + error: str | None = None, + ) -> str: + parsed = urllib.parse.urlparse(redirect_url) + query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + query.append(("status", status)) + if google_email is not None: + query.append(("google_email", google_email)) + if error is not None: + query.append(("error", error)) + return urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(query))) From 0c2e6a93685a7bdffaef32b558a187f648caaf57 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 03:27:38 +0100 Subject: [PATCH 04/26] Add blocked column migration and sqlc updates --- db/generated/models.py | 1 + db/generated/session.py | 21 ++++- db/generated/user.py | 87 ++++++++++++++++--- db/queries/session.sql | 5 ++ db/queries/user.sql | 16 ++++ migrations/sql/down/add-blocked-to-users.sql | 2 + migrations/sql/up/add-blocked-to-users.sql | 2 + .../versions/5b6615c9ab1d_merge_heads.py | 28 ++++++ .../9f1c3c6e9c1a_add_blocked_to_users.py | 25 ++++++ 9 files changed, 174 insertions(+), 13 deletions(-) create mode 100644 migrations/sql/down/add-blocked-to-users.sql create mode 100644 migrations/sql/up/add-blocked-to-users.sql create mode 100644 migrations/versions/5b6615c9ab1d_merge_heads.py create mode 100644 migrations/versions/9f1c3c6e9c1a_add_blocked_to_users.py diff --git a/db/generated/models.py b/db/generated/models.py index db5740d..1111a86 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -221,6 +221,7 @@ class User: updated_at: datetime.datetime display_name: Optional[str] face_embedding: Optional[Any] + blocked: bool deleted_at: Optional[datetime.datetime] diff --git a/db/generated/session.py b/db/generated/session.py index 1b8e026..bc7b427 100644 --- a/db/generated/session.py +++ b/db/generated/session.py @@ -4,7 +4,7 @@ # source: session.sql import dataclasses import datetime -from typing import Optional +from typing import AsyncIterator, Optional import uuid import sqlalchemy @@ -51,6 +51,13 @@ """ +LIST_SESSIONS_BY_USER = """-- name: list_sessions_by_user \\:many +SELECT id, user_id, device_id, created_at, last_active, expires_at +FROM user_sessions +WHERE user_id = :p1 +""" + + UPDATE_SESSION_ACTIVITY = """-- name: update_session_activity \\:exec UPDATE user_sessions SET last_active = NOW() @@ -135,6 +142,18 @@ async def get_session_by_id(self, *, id: uuid.UUID) -> Optional[models.UserSessi expires_at=row[5], ) + async def list_sessions_by_user(self, *, user_id: uuid.UUID) -> AsyncIterator[models.UserSession]: + result = await self._conn.stream(sqlalchemy.text(LIST_SESSIONS_BY_USER), {"p1": user_id}) + async for row in result: + yield models.UserSession( + id=row[0], + user_id=row[1], + device_id=row[2], + created_at=row[3], + last_active=row[4], + expires_at=row[5], + ) + async def update_session_activity(self, *, id: uuid.UUID) -> None: await self._conn.execute(sqlalchemy.text(UPDATE_SESSION_ACTIVITY), {"p1": id}) diff --git a/db/generated/user.py b/db/generated/user.py index 2599d3a..823be6a 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -14,7 +14,7 @@ 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, deleted_at +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at """ @@ -25,33 +25,53 @@ GET_USER_BY_EMAIL = """-- name: get_user_by_email \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at +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 -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at +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 -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at +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 """ +SET_USER_BLOCKED = """-- name: set_user_blocked \\:one +UPDATE users +SET blocked = :p1, + updated_at = NOW() +WHERE id = :p2 +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at +""" + + SET_USER_EMBEDDING = """-- name: set_user_embedding \\:one UPDATE users SET face_embedding = :p1\\:\\:vector, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at +""" + + +UPDATE_USER = """-- name: update_user \\:one +UPDATE users +SET email = COALESCE(:p1, email), + display_name = COALESCE(:p2, display_name), + blocked = COALESCE(:p3, blocked), + updated_at = NOW() +WHERE id = :p4 +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at """ @@ -60,7 +80,7 @@ SET hashed_password = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, blocked, deleted_at """ @@ -80,7 +100,8 @@ async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Op updated_at=row[4], display_name=row[5], face_embedding=row[6], - deleted_at=row[7], + blocked=row[7], + deleted_at=row[8], ) async def delete_user(self, *, id: uuid.UUID) -> None: @@ -98,7 +119,8 @@ async def get_user_by_email(self, *, email: str) -> Optional[models.User]: updated_at=row[4], display_name=row[5], face_embedding=row[6], - deleted_at=row[7], + blocked=row[7], + deleted_at=row[8], ) async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -113,7 +135,8 @@ 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], - deleted_at=row[7], + blocked=row[7], + deleted_at=row[8], ) async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: @@ -127,9 +150,26 @@ async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.U updated_at=row[4], display_name=row[5], face_embedding=row[6], - deleted_at=row[7], + blocked=row[7], + deleted_at=row[8], ) + 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() + if row is None: + return None + return models.User( + id=row[0], + email=row[1], + hashed_password=row[2], + created_at=row[3], + updated_at=row[4], + display_name=row[5], + face_embedding=row[6], + blocked=row[7], + deleted_at=row[8], + ) + 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() if row is None: @@ -142,7 +182,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], - deleted_at=row[7], + 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() + if row is None: + return None + return models.User( + id=row[0], + email=row[1], + hashed_password=row[2], + created_at=row[3], + updated_at=row[4], + display_name=row[5], + face_embedding=row[6], + blocked=row[7], + deleted_at=row[8], ) async def update_user_password(self, *, hashed_password: Optional[str], id: uuid.UUID) -> Optional[models.User]: @@ -157,5 +219,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], - deleted_at=row[7], + blocked=row[7], + deleted_at=row[8], ) diff --git a/db/queries/session.sql b/db/queries/session.sql index 2a5b859..b22911e 100644 --- a/db/queries/session.sql +++ b/db/queries/session.sql @@ -28,6 +28,11 @@ SELECT * FROM user_sessions WHERE id = $1; +-- name: ListSessionsByUser :many +SELECT * +FROM user_sessions +WHERE user_id = $1; + -- name: UpdateSessionActivity :exec UPDATE user_sessions SET last_active = NOW() diff --git a/db/queries/user.sql b/db/queries/user.sql index b9e984e..bc3fdd8 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -20,6 +20,22 @@ SET hashed_password = $1, WHERE id = $2 RETURNING *; +-- name: UpdateUser :one +UPDATE users +SET email = COALESCE($1, email), + display_name = COALESCE($2, display_name), + blocked = COALESCE($3, blocked), + updated_at = NOW() +WHERE id = $4 +RETURNING *; + +-- name: SetUserBlocked :one +UPDATE users +SET blocked = $1, + updated_at = NOW() +WHERE id = $2 +RETURNING *; + -- name: DeleteUser :exec DELETE FROM users WHERE id = $1; diff --git a/migrations/sql/down/add-blocked-to-users.sql b/migrations/sql/down/add-blocked-to-users.sql new file mode 100644 index 0000000..d9bcfd4 --- /dev/null +++ b/migrations/sql/down/add-blocked-to-users.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +DROP COLUMN blocked; diff --git a/migrations/sql/up/add-blocked-to-users.sql b/migrations/sql/up/add-blocked-to-users.sql new file mode 100644 index 0000000..c35e6fd --- /dev/null +++ b/migrations/sql/up/add-blocked-to-users.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN blocked BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/versions/5b6615c9ab1d_merge_heads.py b/migrations/versions/5b6615c9ab1d_merge_heads.py new file mode 100644 index 0000000..cea5228 --- /dev/null +++ b/migrations/versions/5b6615c9ab1d_merge_heads.py @@ -0,0 +1,28 @@ +"""merge_heads + +Revision ID: 5b6615c9ab1d +Revises: 9f1c3c6e9c1a, c3b8d0f1e2a4 +Create Date: 2026-03-20 02:33:56.591359 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5b6615c9ab1d' +down_revision: Union[str, Sequence[str], None] = ('9f1c3c6e9c1a', '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 diff --git a/migrations/versions/9f1c3c6e9c1a_add_blocked_to_users.py b/migrations/versions/9f1c3c6e9c1a_add_blocked_to_users.py new file mode 100644 index 0000000..21b14d1 --- /dev/null +++ b/migrations/versions/9f1c3c6e9c1a_add_blocked_to_users.py @@ -0,0 +1,25 @@ +"""add-blocked-to-users + +Revision ID: 9f1c3c6e9c1a +Revises: 5ead72a95638 +Create Date: 2026-03-20 12:50:00.000000 + +""" +from typing import Sequence, Union + +from migrations.helper import run_sql_down, run_sql_up + + +# revision identifiers, used by Alembic. +revision: str = "9f1c3c6e9c1a" +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: + run_sql_up("add-blocked-to-users") + + +def downgrade() -> None: + run_sql_down("add-blocked-to-users") From d5bf8dcaff17059cdee9ddec0274a4c6ac1bfddb Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 03:27:43 +0100 Subject: [PATCH 05/26] Add token blacklist and blocked checks in auth --- app/core/constant.py | 1 + app/core/token_blacklist.py | 23 ++++++++ app/deps/token_auth.py | 6 ++ app/service/users.py | 114 ++++++++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+) create mode 100644 app/core/token_blacklist.py diff --git a/app/core/constant.py b/app/core/constant.py index 0cae9dc..136d0c7 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -20,6 +20,7 @@ class AuditEventType(str, Enum): UPLOAD_REQUEST_REJECTED = "upload_request.rejected" + BlacklistedSession = "blacklist:session:{session_id}" IMAGE_ALLOWED_TYPES = { "image/jpeg", 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 c5fe522..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: @@ -43,6 +47,8 @@ async def get_current_mobile_user( user = await container.auth_service.user_querier.get_user_by_id(id=session.user_id) if not user: raise HTTPException(status_code=401, detail="User not found") + if user.blocked: + raise HTTPException(status_code=403, detail="User is blocked") return MobileUserSchema( user_id=user.id, diff --git a/app/service/users.py b/app/service/users.py index 0e54045..cc4fff6 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 @@ -85,6 +86,8 @@ async def mobile_register_login( user: User | None = None if existing_user is not None: + if existing_user.blocked: + raise AppException.forbidden("User is blocked") if not verify_password(req.password, existing_user.hashed_password or ""): raise AppException.unauthorized("Invalid credentials") user = existing_user @@ -158,6 +161,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: @@ -174,6 +180,12 @@ async def refresh_token( if not redis_session or redis_session != session_id: raise AppException.unauthorized("Session invalidated") + 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") + await redis.expire(session_key, AuthService.REDIS_SESSION_TTL) new_access_token = create_acces_mobile_token(session_id) @@ -225,6 +237,9 @@ async def validate_session( redis: RedisClient, session_id: str, ) -> bool: + 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: @@ -242,3 +257,102 @@ async def validate_session( 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) + + async def create_user( + self, + *, + email: str, + password: str, + display_name: str | None = None, + blocked: bool = False, + ) -> User: + 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 + + async def get_user(self, *, user_id: uuid.UUID) -> User: + user = await self.user_querier.get_user_by_id(id=user_id) + if not user: + raise AppException.not_found("User not found") + return user + + async def list_users(self, *, limit: int, offset: int) -> list[User]: + users: list[User] = [] + async for user in self.user_querier.list_users(limit=limit, offset=offset): + users.append(user) + return users + + async def update_user( + self, + *, + user_id: uuid.UUID, + email: str | None = None, + display_name: str | None = None, + blocked: bool | None = None, + ) -> User: + 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 From 924da2ce1d54b907fd0c03c8b300be3d54ed72a0 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 03:27:47 +0100 Subject: [PATCH 06/26] Add admin user CRUD and block/unblock endpoints --- app/router/web/__init__.py | 3 + app/router/web/users.py | 155 ++++++++++++++++++++++++++++++++ app/schema/request/web/user.py | 15 ++++ app/schema/response/web/user.py | 13 +++ 4 files changed, 186 insertions(+) create mode 100644 app/router/web/users.py create mode 100644 app/schema/request/web/user.py create mode 100644 app/schema/response/web/user.py diff --git a/app/router/web/__init__.py b/app/router/web/__init__.py index 9b1e12e..b7939c3 100644 --- a/app/router/web/__init__.py +++ b/app/router/web/__init__.py @@ -3,8 +3,11 @@ from app.router.web.event import router as event_router from app.router.web.auth import router as auth_routes from app.router.web.audit import router as audit_router +from app.router.web.users import router as users_router + router = APIRouter(prefix="/admin", tags=["admin"]) router.include_router(staff_users_router) router.include_router(event_router) router.include_router(auth_routes) router.include_router(audit_router) +router.include_router(users_router) diff --git a/app/router/web/users.py b/app/router/web/users.py new file mode 100644 index 0000000..118a8d4 --- /dev/null +++ b/app/router/web/users.py @@ -0,0 +1,155 @@ +from uuid import UUID + +from fastapi import APIRouter, Depends, Query, status + +from app.container import Container, get_container +from app.core.logger import logger +from app.deps.cookie_auth import get_current_staff_user +from app.schema.request.web.user import AdminUserCreateRequest, AdminUserUpdateRequest +from app.schema.response.web.user import AdminUserSchema +from db.generated.models import StaffUser + + +router = APIRouter(prefix="/users") + + +@router.post("/", response_model=AdminUserSchema, status_code=status.HTTP_201_CREATED) +async def create_user( + req: AdminUserCreateRequest, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> AdminUserSchema: + user = await container.auth_service.create_user( + email=req.email, + password=req.password, + display_name=req.display_name, + blocked=req.blocked, + ) + logger.info("admin %s created 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, + ) + + +@router.get("/", response_model=list[AdminUserSchema]) +async def list_users( + limit: int = Query(20, ge=1, le=100), + 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) + 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 + ] + + +@router.get("/{user_id}", response_model=AdminUserSchema) +async def get_user( + user_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> AdminUserSchema: + user = await container.auth_service.get_user(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, + ) + + +@router.put("/{user_id}", response_model=AdminUserSchema) +async def update_user( + user_id: UUID, + req: AdminUserUpdateRequest, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> AdminUserSchema: + user = await container.auth_service.update_user( + user_id=user_id, + email=req.email, + display_name=req.display_name, + blocked=req.blocked, + ) + logger.info("admin %s updated 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, + ) + + +@router.delete("/{user_id}", response_model=AdminUserSchema) +async def delete_user( + user_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> AdminUserSchema: + 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, + ) + + +@router.post("/{user_id}/block", response_model=AdminUserSchema) +async def block_user( + user_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> AdminUserSchema: + user = await container.auth_service.block_user( + redis=container.redis, + user_id=user_id, + ) + logger.info("admin %s blocked 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, + ) + + +@router.post("/{user_id}/unblock", response_model=AdminUserSchema) +async def unblock_user( + user_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> 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) + 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, + ) diff --git a/app/schema/request/web/user.py b/app/schema/request/web/user.py new file mode 100644 index 0000000..2b41695 --- /dev/null +++ b/app/schema/request/web/user.py @@ -0,0 +1,15 @@ +from typing import Optional +from pydantic import BaseModel, EmailStr, Field + + +class AdminUserCreateRequest(BaseModel): + email: EmailStr + password: str = Field(..., min_length=8) + display_name: Optional[str] = None + blocked: bool = False + + +class AdminUserUpdateRequest(BaseModel): + email: Optional[EmailStr] = None + display_name: Optional[str] = None + blocked: Optional[bool] = None diff --git a/app/schema/response/web/user.py b/app/schema/response/web/user.py new file mode 100644 index 0000000..4df356e --- /dev/null +++ b/app/schema/response/web/user.py @@ -0,0 +1,13 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + + +class AdminUserSchema(BaseModel): + id: UUID + email: str + display_name: str | None + blocked: bool + created_at: datetime + updated_at: datetime From 40f6a187be77689ea4287243fccbdbd8600b5ba7 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 03:27:51 +0100 Subject: [PATCH 07/26] Fix staff login crash on missing user --- app/service/staff_user.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/service/staff_user.py b/app/service/staff_user.py index 1da37f5..6241818 100644 --- a/app/service/staff_user.py +++ b/app/service/staff_user.py @@ -110,8 +110,8 @@ async def admin_login( ) -> WebAuthResponse: print("hello") staff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_email(email=email) - if staff is None or not verify_password(password, staff.password): - logger.info(f'user:{staff.email}') # type: ignore + if staff is None or not verify_password(password, staff.password): + logger.info("admin login failed for email %s", email) raise AppException.unauthorized("Invalid email or password") From aeb2eac8097cd791dcb8cd809ee4ec8d751d5f95 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 10:17:42 +0100 Subject: [PATCH 08/26] Add admin and mobile session defaults to settings --- app/core/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/core/config.py b/app/core/config.py index 0fc3d9d..2adf52e 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -35,7 +35,9 @@ class Settings(BaseSettings): MOBILE_SESSION_LIMIT: int = 3 MOBILE_SESSION_TTL_SECONDS: int = 180 MOBILE_SESSION_DAYS: int = 7 - + # Admin list defaults + ADMIN_USERS_DEFAULT_LIMIT: int = 20 + ADMIN_USERS_MAX_LIMIT: int = 100 # Security jwt_secret: str jwt_algorithm: str = "HS256" From a1d054e3f5e3f8e5f7f9adece4fd6d4173c19996 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 10:17:47 +0100 Subject: [PATCH 09/26] Refactor mobile auth endpoints for consistency --- app/router/mobile/auth.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 52e34a1..93e9bce 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -4,7 +4,6 @@ from uuid import UUID from app.container import get_container, Container -from app.core.exceptions import AppException from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.schema.request.mobile.auth import ( @@ -23,7 +22,6 @@ async def mobile_register_login( req: MobileAuthRequest, container: Container = Depends(get_container), ) -> MobileAuthResponse: - return await container.auth_service.mobile_register_login(container.redis, req) @@ -32,20 +30,18 @@ async def refresh_token( req: RefreshTokenRequest, container: Container = Depends(get_container), ) -> MobileAuthResponse: - return await container.auth_service.refresh_token(container.redis, req.refresh_token) @router.post("/logout") async def logout( container: Container = Depends(get_container), - User: MobileUserSchema = Depends(get_current_mobile_user), + current_user: MobileUserSchema = Depends(get_current_mobile_user), ) -> dict[str, str]: - return await container.auth_service.logout( container.redis, - str(User.user_id), - str(User.session_id), + str(current_user.user_id), + str(current_user.session_id), ) @@ -55,7 +51,6 @@ async def revoke_device( container: Container = Depends(get_container), current_user: MobileUserSchema = Depends(get_current_mobile_user), ) -> dict[str, str]: - await container.device_service.revoke_device( device_id=device_id, user_id=current_user.user_id, @@ -99,17 +94,14 @@ async def get_me( current_user: MobileUserSchema = Depends(get_current_mobile_user), container: Container = Depends(get_container), ) -> MeResponse: - - user = await container.auth_service.user_querier.get_user_by_id(id=current_user.user_id) - if user is None : - raise AppException.not_found("user not found") + user = await container.auth_service.get_user(user_id=current_user.user_id) devices, _ = await container.device_service.get_all_devices(current_user.user_id) device_list = [ DeviceSchema( id=d.id, - device_name=d.device_name or "uknown ", - device_type=d.device_type or "uknown ", + device_name=d.device_name or "unknown", + device_type=d.device_type or "unknown", totp_secret=d.totp_secret, ) for d in devices @@ -128,8 +120,6 @@ async def get_me( expires_at=sessions_objs.expires_at, ) - - return MeResponse( user=UserSchema(id=user.id, email=user.email), devices=device_list, From 5c583b6aea5729104790dadc9021a05d6418b91d Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 10:18:00 +0100 Subject: [PATCH 10/26] Refactor admin users router mappings and defaults --- app/router/web/users.py | 83 +++++++++++------------------------------ 1 file changed, 22 insertions(+), 61 deletions(-) diff --git a/app/router/web/users.py b/app/router/web/users.py index 118a8d4..59af708 100644 --- a/app/router/web/users.py +++ b/app/router/web/users.py @@ -3,15 +3,26 @@ from fastapi import APIRouter, Depends, Query, status from app.container import Container, get_container +from app.core.config import settings from app.core.logger import logger from app.deps.cookie_auth import get_current_staff_user from app.schema.request.web.user import AdminUserCreateRequest, AdminUserUpdateRequest from app.schema.response.web.user import AdminUserSchema -from db.generated.models import StaffUser +from db.generated.models import StaffUser, User router = APIRouter(prefix="/users") +def _to_admin_user_schema(user: User) -> AdminUserSchema: + 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, + ) + @router.post("/", response_model=AdminUserSchema, status_code=status.HTTP_201_CREATED) async def create_user( @@ -26,35 +37,20 @@ async def create_user( blocked=req.blocked, ) logger.info("admin %s created 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, - ) + return _to_admin_user_schema(user) @router.get("/", response_model=list[AdminUserSchema]) async def list_users( - limit: int = Query(20, ge=1, le=100), + limit: int = Query( + settings.ADMIN_USERS_DEFAULT_LIMIT, ge=1, le=settings.ADMIN_USERS_MAX_LIMIT + ), 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) - 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 - ] + return [_to_admin_user_schema(user) for user in users] @router.get("/{user_id}", response_model=AdminUserSchema) @@ -64,14 +60,7 @@ async def get_user( container: Container = Depends(get_container), ) -> AdminUserSchema: user = await container.auth_service.get_user(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, - ) + return _to_admin_user_schema(user) @router.put("/{user_id}", response_model=AdminUserSchema) @@ -88,14 +77,7 @@ async def update_user( blocked=req.blocked, ) logger.info("admin %s updated 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, - ) + return _to_admin_user_schema(user) @router.delete("/{user_id}", response_model=AdminUserSchema) @@ -106,14 +88,7 @@ async def delete_user( ) -> AdminUserSchema: 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, - ) + return _to_admin_user_schema(user) @router.post("/{user_id}/block", response_model=AdminUserSchema) @@ -127,14 +102,7 @@ async def block_user( user_id=user_id, ) logger.info("admin %s blocked 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, - ) + return _to_admin_user_schema(user) @router.post("/{user_id}/unblock", response_model=AdminUserSchema) @@ -145,11 +113,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) - 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, - ) + return _to_admin_user_schema(user) From 96ff5fd1ae277f9d2523b5540da7be8d2f696196 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 10:18:09 +0100 Subject: [PATCH 11/26] Use settings and consistent DB error handling in user service --- app/service/users.py | 162 ++++++++++++++++++++++++++----------------- 1 file changed, 100 insertions(+), 62 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index cc4fff6..cbbe1b2 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -2,7 +2,7 @@ import uuid from app.core import constant -from app.core.exceptions import AppException +from app.core.exceptions import AppException, DBException from app.core.securite import ( # EmbeddingCrypto, hash_password, @@ -266,26 +266,32 @@ async def create_user( display_name: str | None = None, blocked: bool = False, ) -> User: - 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, + try: + hashed = hash_password(password) + user = await self.user_querier.create_user( + email=email, + hashed_password=hashed, ) - if not updated: - raise AppException.internal_error("Failed to update user") - return updated + if not user: + raise AppException.internal_error("Failed to create user") - return 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 + except AppException: + raise + except Exception as exc: + logger.error("Failed to create user: %s", exc) + raise DBException.handle(exc) async def get_user(self, *, user_id: uuid.UUID) -> User: user = await self.user_querier.get_user_by_id(id=user_id) @@ -294,10 +300,14 @@ async def get_user(self, *, user_id: uuid.UUID) -> User: return user async def list_users(self, *, limit: int, offset: int) -> list[User]: - users: list[User] = [] - async for user in self.user_querier.list_users(limit=limit, offset=offset): - users.append(user) - return users + try: + users: list[User] = [] + async for user in self.user_querier.list_users(limit=limit, offset=offset): + users.append(user) + return users + except Exception as exc: + logger.error("Failed to list users: %s", exc) + raise DBException.handle(exc) async def update_user( self, @@ -307,52 +317,80 @@ async def update_user( display_name: str | None = None, blocked: bool | None = None, ) -> User: - 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 + try: + 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 + 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, + ) - 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 AppException: + raise + 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 + 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 AppException: + raise + except Exception as exc: + logger.error("Failed to unblock user: %s", exc) + raise DBException.handle(exc) From 016ea29d9a09a815cb18eb6e4c308915f3e18734 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Fri, 20 Mar 2026 10:23:33 +0100 Subject: [PATCH 12/26] Fix mypy exception handling in user service --- app/service/users.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index cbbe1b2..0844d97 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -2,6 +2,7 @@ import uuid from app.core import constant +from fastapi import HTTPException from app.core.exceptions import AppException, DBException from app.core.securite import ( # EmbeddingCrypto, @@ -287,7 +288,7 @@ async def create_user( return updated return user - except AppException: + except HTTPException: raise except Exception as exc: logger.error("Failed to create user: %s", exc) @@ -337,7 +338,7 @@ async def update_user( if not user: raise AppException.internal_error("Failed to update user") return user - except AppException: + except HTTPException: raise except Exception as exc: logger.error("Failed to update user: %s", exc) @@ -350,7 +351,7 @@ async def delete_user(self, *, user_id: uuid.UUID) -> User: raise AppException.not_found("User not found") await self.user_querier.delete_user(id=user_id) return existing - except AppException: + except HTTPException: raise except Exception as exc: logger.error("Failed to delete user: %s", exc) @@ -377,7 +378,7 @@ async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: await redis.delete(session_key) return user - except AppException: + except HTTPException: raise except Exception as exc: logger.error("Failed to block user: %s", exc) @@ -389,7 +390,7 @@ async def unblock_user(self, *, user_id: uuid.UUID) -> User: if not user: raise AppException.not_found("User not found") return user - except AppException: + except HTTPException: raise except Exception as exc: logger.error("Failed to unblock user: %s", exc) From 46dff8ed95fe4d2785eb6ea19e4c883d542e6802 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:21:25 +0100 Subject: [PATCH 13/26] refactor: move admin user mapping out of router --- app/router/web/users.py | 34 +++++++++++++--------------------- app/service/users.py | 37 ++++++------------------------------- 2 files changed, 19 insertions(+), 52 deletions(-) diff --git a/app/router/web/users.py b/app/router/web/users.py index 59af708..4866d5f 100644 --- a/app/router/web/users.py +++ b/app/router/web/users.py @@ -7,23 +7,12 @@ from app.core.logger import logger from app.deps.cookie_auth import get_current_staff_user from app.schema.request.web.user import AdminUserCreateRequest, AdminUserUpdateRequest -from app.schema.response.web.user import AdminUserSchema -from db.generated.models import StaffUser, User +from app.schema.response.web.user import AdminUserSchema, to_admin_user_schema +from db.generated.models import StaffUser router = APIRouter(prefix="/users") -def _to_admin_user_schema(user: User) -> AdminUserSchema: - 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, - ) - - @router.post("/", response_model=AdminUserSchema, status_code=status.HTTP_201_CREATED) async def create_user( req: AdminUserCreateRequest, @@ -37,7 +26,7 @@ async def create_user( blocked=req.blocked, ) logger.info("admin %s created user %s", current_staff_user.id, user.id) - return _to_admin_user_schema(user) + return to_admin_user_schema(user) @router.get("/", response_model=list[AdminUserSchema]) @@ -50,7 +39,7 @@ async def list_users( container: Container = Depends(get_container), ) -> list[AdminUserSchema]: users = await container.auth_service.list_users(limit=limit, offset=offset) - return [_to_admin_user_schema(user) for user in users] + return [to_admin_user_schema(user) for user in users] @router.get("/{user_id}", response_model=AdminUserSchema) @@ -60,7 +49,7 @@ async def get_user( container: Container = Depends(get_container), ) -> AdminUserSchema: user = await container.auth_service.get_user(user_id=user_id) - return _to_admin_user_schema(user) + return to_admin_user_schema(user) @router.put("/{user_id}", response_model=AdminUserSchema) @@ -77,7 +66,7 @@ async def update_user( blocked=req.blocked, ) logger.info("admin %s updated user %s", current_staff_user.id, user_id) - return _to_admin_user_schema(user) + return to_admin_user_schema(user) @router.delete("/{user_id}", response_model=AdminUserSchema) @@ -86,9 +75,12 @@ async def delete_user( current_staff_user: StaffUser = Depends(get_current_staff_user), container: Container = Depends(get_container), ) -> AdminUserSchema: - user = await container.auth_service.delete_user(user_id=user_id) + 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) + return to_admin_user_schema(user) @router.post("/{user_id}/block", response_model=AdminUserSchema) @@ -102,7 +94,7 @@ async def block_user( user_id=user_id, ) logger.info("admin %s blocked user %s", current_staff_user.id, user_id) - return _to_admin_user_schema(user) + return to_admin_user_schema(user) @router.post("/{user_id}/unblock", response_model=AdminUserSchema) @@ -113,4 +105,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) - return _to_admin_user_schema(user) + return to_admin_user_schema(user) diff --git a/app/service/users.py b/app/service/users.py index 0844d97..6ef30c7 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -2,7 +2,6 @@ import uuid from app.core import constant -from fastapi import HTTPException from app.core.exceptions import AppException, DBException from app.core.securite import ( # EmbeddingCrypto, @@ -107,8 +106,6 @@ async def mobile_register_login( user_id: uuid.UUID = user.id session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id) - if await redis.exists(session_key): - raise AppException.forbidden("User already has an active session") session_count = await self.session_querier.count_user_sessions(user_id=user_id) if session_count and session_count >= AuthService.SESSION_LIMIT: @@ -173,22 +170,12 @@ async def refresh_token( if session.expires_at < datetime.now(timezone.utc): raise AppException.unauthorized("Session expired") - 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") - 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") - await redis.expire(session_key, AuthService.REDIS_SESSION_TTL) - new_access_token = create_acces_mobile_token(session_id) new_refresh_token = create_refresh_mobile_token(session_id) expiry = Get_expiry_time() @@ -248,13 +235,7 @@ async def validate_session( if session.expires_at < datetime.now(timezone.utc): return False - - session_key = constant.RedisKey.UserSessionByUser.value.format( - user_id=session.user_id - ) - redis_session = await redis.get(session_key) - - return redis_session == session_id + 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) @@ -288,8 +269,6 @@ async def create_user( return updated return user - except HTTPException: - raise except Exception as exc: logger.error("Failed to create user: %s", exc) raise DBException.handle(exc) @@ -338,21 +317,21 @@ async def update_user( if not user: raise AppException.internal_error("Failed to update user") return user - except HTTPException: - 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: + async def delete_user(self, *, redis: RedisClient, 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) + session_key = constant.RedisKey.UserSessionByUser.value.format( + user_id=user_id + ) + await redis.delete(session_key) return existing - except HTTPException: - raise except Exception as exc: logger.error("Failed to delete user: %s", exc) raise DBException.handle(exc) @@ -378,8 +357,6 @@ async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: await redis.delete(session_key) return user - except HTTPException: - raise except Exception as exc: logger.error("Failed to block user: %s", exc) raise DBException.handle(exc) @@ -390,8 +367,6 @@ async def unblock_user(self, *, user_id: uuid.UUID) -> User: if not user: raise AppException.not_found("User not found") return user - except HTTPException: - raise except Exception as exc: logger.error("Failed to unblock user: %s", exc) raise DBException.handle(exc) From 7d9d21589a0c92bae64e7922ef2f929000927ef9 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:21:25 +0100 Subject: [PATCH 14/26] feat: add admin user schema mapper --- app/schema/response/web/user.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/schema/response/web/user.py b/app/schema/response/web/user.py index 4df356e..bd79627 100644 --- a/app/schema/response/web/user.py +++ b/app/schema/response/web/user.py @@ -2,6 +2,7 @@ from uuid import UUID from pydantic import BaseModel +from db.generated.models import User class AdminUserSchema(BaseModel): @@ -11,3 +12,14 @@ class AdminUserSchema(BaseModel): blocked: bool created_at: datetime updated_at: datetime + + +def to_admin_user_schema(user: User) -> AdminUserSchema: + 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, + ) From 0bf1e5b51865ab9822ee18a0dadf4eb855682eec Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:21:26 +0100 Subject: [PATCH 15/26] fix: rely on db for session validity in auth service --- app/service/users.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index 6ef30c7..eb853da 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -13,7 +13,6 @@ 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 @@ -159,9 +158,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: @@ -225,9 +221,6 @@ async def validate_session( redis: RedisClient, session_id: str, ) -> bool: - 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: @@ -342,15 +335,6 @@ async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: 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 ) From 0f0059e0d63d8ac35380410e5ec6fdeda95eb0f2 Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:21:26 +0100 Subject: [PATCH 16/26] chore: deprecate token blacklist helpers --- app/core/token_blacklist.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/app/core/token_blacklist.py b/app/core/token_blacklist.py index 3f22038..a722935 100644 --- a/app/core/token_blacklist.py +++ b/app/core/token_blacklist.py @@ -1,23 +1,19 @@ -from datetime import datetime, timezone +from datetime import datetime -from app.core.constant import RedisKey from app.infra.redis import RedisClient +# Deprecated: sessions are validated against the database as the source of truth. +# Keep these helpers as no-ops to avoid breaking callers while we remove usage. 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) + _ = (redis, session_id, expires_at) + return None 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) + _ = (redis, session_id) + return False From 7124ec15f3132f2b15b1337533250538d3b8881d Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:21:26 +0100 Subject: [PATCH 17/26] fix: validate sessions via db in token auth --- app/deps/token_auth.py | 4 ---- 1 file changed, 4 deletions(-) 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: From 97ca3430dcacd506950c359cad922d74b7ec662e Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 21:32:07 +0100 Subject: [PATCH 18/26] chore: remove token blacklist helpers --- app/core/token_blacklist.py | 19 ------------------- 1 file changed, 19 deletions(-) delete mode 100644 app/core/token_blacklist.py diff --git a/app/core/token_blacklist.py b/app/core/token_blacklist.py deleted file mode 100644 index a722935..0000000 --- a/app/core/token_blacklist.py +++ /dev/null @@ -1,19 +0,0 @@ -from datetime import datetime - -from app.infra.redis import RedisClient - - -# Deprecated: sessions are validated against the database as the source of truth. -# Keep these helpers as no-ops to avoid breaking callers while we remove usage. -async def blacklist_session( - redis: RedisClient, - session_id: str, - expires_at: datetime | None = None, -) -> None: - _ = (redis, session_id, expires_at) - return None - - -async def is_session_blacklisted(redis: RedisClient, session_id: str) -> bool: - _ = (redis, session_id) - return False From 6a2a8586c44a739d9a1f242b0e2f68e6b8b0d8cb Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 23:15:36 +0100 Subject: [PATCH 19/26] Remove GH Actions cache from docker publish --- .github/workflows/docker-publish.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5f14801..180ecfa 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -55,5 +55,3 @@ jobs: platforms: linux/amd64,linux/arm64 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max From ea71e2d24def78c8fe1c546f0abd7b9238f8047e Mon Sep 17 00:00:00 2001 From: bouhamza abderrahmane Date: Mon, 23 Mar 2026 23:39:26 +0100 Subject: [PATCH 20/26] Use explicit image tags in docker publish workflow --- .github/workflows/docker-publish.yml | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 180ecfa..d946aae 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -38,20 +38,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=raw,value=latest - type=sha,prefix= - - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ github.sha }} From 0dab21a9e3f7873ad571f71e4fee0e15fd7204a0 Mon Sep 17 00:00:00 2001 From: wailbentafat Date: Wed, 25 Mar 2026 16:31:24 +0100 Subject: [PATCH 21/26] fix .gitignore for firebase info --- .gitignore | 1 + app/service/event.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 5cc1fe4..85a8183 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ db/schema.sql multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json db.txt +multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json \ No newline at end of file diff --git a/app/service/event.py b/app/service/event.py index 27e8e81..9723a06 100644 --- a/app/service/event.py +++ b/app/service/event.py @@ -12,7 +12,6 @@ UserEventResponse, ParticipantResponse ) -# Ensure these imports match your actual folder structure from db.generated import events as event_queries from db.generated import eventParticipant as participant_queries from db.generated import models From 143cf1dcb30fa56c18e29def0d9f13b1e61a6879 Mon Sep 17 00:00:00 2001 From: maya-ots Date: Wed, 25 Mar 2026 20:23:38 +0100 Subject: [PATCH 22/26] feat : add compute_event_embedding to FaceEmbeddingService (#31) * feat : add compute_event_embedding to FaceEmbeddingService * chore: add .venv to gitignore --------- Co-authored-by: bentafat wail <150479778+wailbentafat@users.noreply.github.com> --- .gitignore | 3 +- app/service/face_embedding.py | 33 +++++++++++++++++++ ...MultiAImultAI-back.venvScriptsActivate.ps1 | 7 ++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 ersLenovoOneDriveDesktopMultiAImultAI-back.venvScriptsActivate.ps1 diff --git a/.gitignore b/.gitignore index 85a8183..5593ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ db/schema.sql multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json db.txt -multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json \ No newline at end of file +.venv +multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json diff --git a/app/service/face_embedding.py b/app/service/face_embedding.py index 11a0d81..076d29e 100644 --- a/app/service/face_embedding.py +++ b/app/service/face_embedding.py @@ -151,6 +151,39 @@ async def compute_average_embedding( return averaged.astype(float).tolist() + async def compute_event_embedding( + self, + payloads: Sequence[FaceImagePayload], + ) -> dict[str, list[list[float]]]: + + if not payloads: + raise AppException.bad_request( + "At least one image is required" + ) + + results: dict[str, list[list[float]]] = {} + + for payload in payloads: + try: + image = self._decode_image(payload) + image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + + faces: list[FaceStub] = await asyncio.to_thread( + self.face_embedding.model.get, image_rgb # type: ignore + ) + + results[payload["filename"]] = [ + face.embedding.flatten().tolist() + for face in faces + if face.embedding is not None + ] + + except Exception as e: + print(f"[FaceEmbeddingService] Skipping {payload['filename']}: {e}") + results[payload["filename"]] = [] + + return results + def _decode_image(self, payload: FaceImagePayload) -> np.ndarray: buffer = np.frombuffer(payload["bytes"], dtype=np.uint8) diff --git a/ersLenovoOneDriveDesktopMultiAImultAI-back.venvScriptsActivate.ps1 b/ersLenovoOneDriveDesktopMultiAImultAI-back.venvScriptsActivate.ps1 new file mode 100644 index 0000000..d27c36e --- /dev/null +++ b/ersLenovoOneDriveDesktopMultiAImultAI-back.venvScriptsActivate.ps1 @@ -0,0 +1,7 @@ + Maya/2-ai-face-detection + Maya/3-ai-face-embedding + feat/ai_pipline +* feat/event-face-embeddings + fix/optimize-face-detection + fix/remove-redundant-face-detection + main From b04c92981698f3325c449088a931a35e9a844251 Mon Sep 17 00:00:00 2001 From: bentafat wail <150479778+wailbentafat@users.noreply.github.com> Date: Wed, 25 Mar 2026 20:25:24 +0100 Subject: [PATCH 23/26] feat: add storage cleaner worker (#33) --- app/core/constant.py | 3 + app/infra/nats.py | 7 +- app/worker/storage_cleaner/main.py | 158 +++++++++++++++++++++++++ app/worker/storage_cleaner/settings.py | 31 +++++ 4 files changed, 198 insertions(+), 1 deletion(-) diff --git a/app/core/constant.py b/app/core/constant.py index 136d0c7..9f1f512 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -9,6 +9,9 @@ class RedisKey(str, Enum): NOTIFICATION_EVENT_SUBJECT = "notification_event" AUDIT_EVENT_SUBJECT = "audit.event" +FINAL_BUCKET_CLEANUP_SUBJECT = "ai.final_bucket.completed" +FINAL_BUCKET_CLEANUP_STREAM = "ai-final-bucket-cleanup" +FINAL_BUCKET_CLEANUP_DURABLE_NAME = "ai-final-bucket-cleaner" class AuditEventType(str, Enum): diff --git a/app/infra/nats.py b/app/infra/nats.py index 5a9a101..cf5b91e 100644 --- a/app/infra/nats.py +++ b/app/infra/nats.py @@ -7,7 +7,11 @@ from pydantic import BaseModel from app.core.config import settings -from app.core.constant import NOTIFICATION_EVENT_SUBJECT, AUDIT_EVENT_SUBJECT +from app.core.constant import ( + NOTIFICATION_EVENT_SUBJECT, + AUDIT_EVENT_SUBJECT, + FINAL_BUCKET_CLEANUP_SUBJECT, +) class Message(BaseModel): @@ -20,6 +24,7 @@ class NatsSubjects(Enum): USER_LOGOUT = "user.logout" NOTIFICATION_EVENT = NOTIFICATION_EVENT_SUBJECT AUDIT_EVENT = AUDIT_EVENT_SUBJECT + FINAL_BUCKET_CLEANUP = FINAL_BUCKET_CLEANUP_SUBJECT STAFF_UPLOAD_REQUEST_CREATED = "staff.upload_request.created" STAFF_UPLOAD_REQUEST_APPROVED = "staff.upload_request.approved" STAFF_UPLOAD_REQUEST_REJECTED = "staff.upload_request.rejected" diff --git a/app/worker/storage_cleaner/main.py b/app/worker/storage_cleaner/main.py index e69de29..344049c 100644 --- a/app/worker/storage_cleaner/main.py +++ b/app/worker/storage_cleaner/main.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import asyncio +import json +import uuid +from typing import Iterable, Optional, Set, Tuple + +import sqlalchemy.ext.asyncio +from fastapi import HTTPException +from pydantic import BaseModel, ValidationError + +from app.core.logger import logger +from app.infra.database import engine +from app.infra.nats import NatsClient +from app.service.staged_upload_storage import StagedUploadStorageService +from db.generated import upload_request_photos as upload_request_photo_queries +from app.worker.storage_cleaner.settings import settings + + +class FinalBucketCleanupPayload(BaseModel): + storage_keys: list[str] = [] + photo_ids: list[str] | None = None + ids: list[str] | None = None + + +storage_service = StagedUploadStorageService() + + +async def create_photo_querier() -> Tuple[ + sqlalchemy.ext.asyncio.AsyncConnection, + upload_request_photo_queries.AsyncQuerier, +]: + conn = await engine.connect() + querier = upload_request_photo_queries.AsyncQuerier(conn) + return conn, querier + + +async def close_connection(conn: sqlalchemy.ext.asyncio.AsyncConnection) -> None: + await conn.close() + + +def _parse_payload(raw_data: bytes | str) -> Optional[FinalBucketCleanupPayload]: + if isinstance(raw_data, bytes): + try: + raw_data = raw_data.decode("utf-8") + except UnicodeDecodeError as exc: + logger.warning("Final bucket cleanup payload failed to decode: %s", exc) + return None + + try: + parsed = json.loads(raw_data) + except (json.JSONDecodeError, TypeError) as exc: + logger.warning("Final bucket cleanup payload is invalid JSON: %s", exc) + return None + + if not isinstance(parsed, dict): + return None + + try: + return FinalBucketCleanupPayload.model_validate(parsed) + except ValidationError as exc: + logger.warning("Final bucket cleanup payload validation failed: %s", exc) + return None + + +async def resolve_final_storage_keys( + payload: FinalBucketCleanupPayload, + querier: upload_request_photo_queries.AsyncQuerier, +) -> Set[str]: + storage_keys: Set[str] = set(payload.storage_keys) + photo_ids = payload.photo_ids or payload.ids + if photo_ids: + storage_keys.update(await _fetch_keys_for_ids(photo_ids, querier)) + return storage_keys + + +async def _fetch_keys_for_ids( + photo_ids: Iterable[str], + querier: upload_request_photo_queries.AsyncQuerier, +) -> Set[str]: + keys: Set[str] = set() + for raw_id in photo_ids: + try: + photo_id = uuid.UUID(raw_id) + except ValueError: + logger.warning("Skipping invalid photo id %s", raw_id) + continue + photo = await querier.get_upload_request_photo_by_id(id=photo_id) + if photo is None: + logger.warning("No upload request photo found for %s", raw_id) + continue + if photo.final_storage_key is None: + logger.warning("Upload request photo %s has no final storage key", raw_id) + continue + keys.add(photo.final_storage_key) + return keys + + +async def _delete_storage_key(storage_key: str) -> None: + try: + await storage_service.delete_storage_key(storage_key) + logger.info("Removed finalized storage key %s", storage_key) + except HTTPException as exc: + detail = getattr(exc, "detail", exc) + logger.warning("Skipping cleanup for %s: %s", storage_key, detail) + except Exception: + logger.exception("Failed to delete %s, worker will retry", storage_key) + raise + + +async def _handle_cleanup_event( + raw_payload: bytes | str, + querier: upload_request_photo_queries.AsyncQuerier, +) -> None: + payload = _parse_payload(raw_payload) + if payload is None: + return + + storage_keys = await resolve_final_storage_keys(payload, querier) + if not storage_keys: + logger.info("Final bucket cleanup event contained no storage keys") + return + + logger.info( + "Cleaning %d finalized storage objects from JetStream schedule", + len(storage_keys), + ) + + for storage_key in storage_keys: + await _delete_storage_key(storage_key) + + +async def main() -> None: + conn, querier = await create_photo_querier() + await NatsClient.connect() + try: + async def _jetstream_handler(data: bytes | str) -> None: + await _handle_cleanup_event(data, querier) + + await NatsClient.js_subscribe( + subject=settings.subject_enum, + callback=_jetstream_handler, + stream_name=settings.stream_name, + durable_name=settings.durable_name, + ) + logger.info( + "Storage cleaner listening on %s for %d-day window", + settings.subject, + settings.WINDOW_DAYS, + ) + await asyncio.Event().wait() + finally: + await close_connection(conn) + await NatsClient.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/worker/storage_cleaner/settings.py b/app/worker/storage_cleaner/settings.py index e69de29..23a56f6 100644 --- a/app/worker/storage_cleaner/settings.py +++ b/app/worker/storage_cleaner/settings.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from pydantic import Field +from pydantic_settings import BaseSettings + +from app.core.constant import ( + FINAL_BUCKET_CLEANUP_DURABLE_NAME, + FINAL_BUCKET_CLEANUP_STREAM, + FINAL_BUCKET_CLEANUP_SUBJECT, +) +from app.infra.nats import NatsSubjects + + +class StorageCleanerSettings(BaseSettings): + subject: str = Field(FINAL_BUCKET_CLEANUP_SUBJECT) + stream_name: str = Field(FINAL_BUCKET_CLEANUP_STREAM) + durable_name: str = Field(FINAL_BUCKET_CLEANUP_DURABLE_NAME) + WINDOW_DAYS = 7 + + class Config: + env_prefix = "STORAGE_CLEANER_" + + @property + def subject_enum(self) -> NatsSubjects: + try: + return NatsSubjects(self.subject) + except ValueError: + return NatsSubjects.FINAL_BUCKET_CLEANUP + + +settings = StorageCleanerSettings() # type: ignore From 297a707cbe4b7202e3cc395744e1da5ba28b6a8e Mon Sep 17 00:00:00 2001 From: Abderrahmane Bouhamza <165963341+Tyjfre-j@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:35:24 +0100 Subject: [PATCH 24/26] Feat/ai jetstream listener (#30) * feat: return all detected faces with embeddings * feat: add batch face embedding service * feat: add batch face embeddings endpoint * chore: register batch face embeddings router * feat: wire batch face embedding service in container * feat: add photo_faces upsert query * chore: add generated photo_faces querier * feat: add batch face embeddings request schema * feat: add batch face embeddings response schema * fix: match photo_faces upsert signature * fix: commit/rollback per face and serialize bbox floats * feat: wire batch queue service into container * chore: add face-embedding stream settings * feat: add batch embeddings subject and ensure stream * feat: switch batch endpoint to enqueue jobs (202) * feat: add batch face embedding request DTO * feat: add batch face embedding enqueue response * feat: add batch face embedding job DTO * feat: enqueue batch face embedding jobs to JetStream * feat: add JetStream worker for batch face embeddings * chore: migrate photo_faces embedding to 512 dims * chore: merge alembic heads * chore: add sql up/down for photo_faces embedding dim change * chore: centralize shared content-type and url constants * chore: use default content type constant in MinIO * chore: use default content type constant in enrollment * chore: use constants for content type and source parsing * fix: refactor batch face embedding flow for clarity * chore: update generated db queriers * Add token blacklist and blocked checks in auth * Add admin user CRUD and block/unblock endpoints * Use settings and consistent DB error handling in user service * chore: remove token blacklist helpers * chore: remove legacy batch face worker * feat: add single-face match worker * refactor: move single-face worker into folder * refactor: move single-face worker into folder * feat: harden single-face match processing * chore: improve worker shutdown behavior * refactor: move constants into core * refactor: move settings into config * refactor: use core constants for MinIO buckets * refactor: use core constants for Google URLs * refactor: load face model settings from config * refactor: use config for MinIO retries * WIP: save work before rebase * Bon. --- app/container.py | 3 +- app/core/config.py | 32 +- app/core/constant.py | 15 +- app/core/exceptions.py | 3 + app/infra/google_drive.py | 10 +- app/infra/minio.py | 17 +- app/infra/nats.py | 23 +- app/router/mobile/enrollement.py | 10 +- app/router/web/users.py | 2 - app/schema/dto/single_face_match.py | 25 ++ app/service/face_embedding.py | 51 ++- app/service/session.py | 2 + app/service/single_face_match.py | 301 ++++++++++++++++++ app/service/users.py | 10 +- app/worker/single_face_match/__init__.py | 1 + app/worker/single_face_match/worker.py | 85 +++++ db/generated/models.py | 1 + db/generated/photo_faces.py | 50 +++ db/generated/user.py | 77 ++++- db/queries/photo_faces.sql | 13 + .../down/alter-photo-faces-embedding-dim.sql | 2 + .../up/alter-photo-faces-embedding-dim.sql | 2 + .../versions/4dd6658b9f83_merge_heads.py | 28 ++ ...b4a3d21_alter_photo_faces_embedding_dim.py | 24 ++ 24 files changed, 736 insertions(+), 51 deletions(-) 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/__init__.py create mode 100644 app/worker/single_face_match/worker.py create mode 100644 db/generated/photo_faces.py create mode 100644 db/queries/photo_faces.sql create mode 100644 migrations/sql/down/alter-photo-faces-embedding-dim.sql create mode 100644 migrations/sql/up/alter-photo-faces-embedding-dim.sql create mode 100644 migrations/versions/4dd6658b9f83_merge_heads.py create mode 100644 migrations/versions/9f6c1b4a3d21_alter_photo_faces_embedding_dim.py diff --git a/app/container.py b/app/container.py index fd94a4f..be30b37 100644 --- a/app/container.py +++ b/app/container.py @@ -17,6 +17,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 +55,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) @@ -115,7 +117,6 @@ def __init__( ) 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 2adf52e..55d0841 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,4 +1,5 @@ -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic import field_validator class Settings(BaseSettings): @@ -16,6 +17,8 @@ class Settings(BaseSettings): NATS_HOST: str NATS_PASSWORD: str NATS_USER: str + NATS_SINGLE_FACE_MATCH_STREAM: str = "single_face_matches" + NATS_SINGLE_FACE_MATCH_DURABLE: str = "single_face_match_worker" # MinIO @@ -23,6 +26,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 @@ -44,6 +49,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 = "" @@ -55,9 +67,21 @@ 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", + ) + + @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/constant.py b/app/core/constant.py index 9f1f512..0925bbd 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -23,8 +23,6 @@ class AuditEventType(str, Enum): UPLOAD_REQUEST_REJECTED = "upload_request.rejected" - BlacklistedSession = "blacklist:session:{session_id}" - IMAGE_ALLOWED_TYPES = { "image/jpeg", "image/png", @@ -32,6 +30,19 @@ 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://" + +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 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/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 diff --git a/app/infra/minio.py b/app/infra/minio.py index 09104ea..e6249da 100644 --- a/app/infra/minio.py +++ b/app/infra/minio.py @@ -9,11 +9,18 @@ from app.core.utils import check_extension from app.core.exceptions import AppException +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, +) -IMAGES_BUCKET_NAME = "images" -DOCUMENTS_BUCKET_NAME = "documents" -WA_SIM_BUCKET_NAME = "wa-sim" +# 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 @@ -48,7 +55,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 +87,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}") diff --git a/app/infra/nats.py b/app/infra/nats.py index cf5b91e..5f01b55 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" + SINGLE_FACE_MATCH_REQUESTED = "photo_faces.single.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, + ), + ) 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, ) diff --git a/app/router/web/users.py b/app/router/web/users.py index 4866d5f..f167376 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,7 +27,6 @@ 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( 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/face_embedding.py b/app/service/face_embedding.py index 076d29e..71295e3 100644 --- a/app/service/face_embedding.py +++ b/app/service/face_embedding.py @@ -1,11 +1,13 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from typing import List, Literal, Optional, Sequence, Tuple, TypedDict 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 @@ -27,18 +29,35 @@ 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, - 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 @@ -184,6 +203,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) 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 new file mode 100644 index 0000000..30676c1 --- /dev/null +++ b/app/service/single_face_match.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +import asyncio +import json +from dataclasses import dataclass +from uuid import UUID + +import sqlalchemy +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 +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 <=> CAST(:embedding AS 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 +""" + +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 +""" + + + +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: # 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)", + job.photo_id, + job.faces_detected, + ) + 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 + + 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 + + match = await self._find_closest_user(embedding) + if match is None: + logger.info("No user embeddings available for matching") + return + + 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( + 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 = 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 + + 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 + + async def _load_payload(self, job: SingleFaceMatchJob) -> FaceImagePayload: + if not job.image_ref: + 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, settings.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, + settings.MINIO_RETRY_ATTEMPTS, + exc, + ) + if attempt < settings.MINIO_RETRY_ATTEMPTS: + await asyncio.sleep(settings.MINIO_RETRY_BASE_SECONDS * attempt) + assert last_exc is not None + raise last_exc + + 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 _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 diff --git a/app/service/users.py b/app/service/users.py index eb853da..8ab3f81 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -1,10 +1,8 @@ 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 ( - # EmbeddingCrypto, hash_password, verify_password, create_acces_mobile_token, @@ -12,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 @@ -143,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, ) @@ -197,16 +196,13 @@ async def add_embbed_user( self, user_id: uuid.UUID, image_payloads: list[FaceImagePayload], - ) ->User: + ) -> User: logger.info("Generating face embeddings for user %s", user_id) 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, 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..fa8ef37 --- /dev/null +++ b/app/worker/single_face_match/worker.py @@ -0,0 +1,85 @@ +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 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 +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) + 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, + 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") + 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__": + asyncio.run(run_worker()) 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/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], + ) diff --git a/db/generated/user.py b/db/generated/user.py index 823be6a..e812192 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -88,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( @@ -108,7 +114,11 @@ 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( @@ -124,7 +134,9 @@ async def get_user_by_email(self, *, email: str) -> Optional[models.User]: ) 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( @@ -139,8 +151,12 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: deleted_at=row[8], ) - 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], @@ -155,7 +171,11 @@ async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.U ) 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( @@ -171,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( @@ -186,13 +210,25 @@ async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[ 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( @@ -207,8 +243,15 @@ async def update_user(self, *, email: str, display_name: Optional[str], blocked: deleted_at=row[8], ) - 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( 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 *; 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); 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 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 9b5d875ea6ab0fd9f579aa4befd056b43b6fcdeb Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 25 Mar 2026 23:23:23 +0100 Subject: [PATCH 25/26] Add grouped Google Drive folder import for staff uploads --- app/container.py | 3 + app/infra/google_drive.py | 68 ++ app/infra/nats.py | 3 + app/router/staff/uploads.py | 88 +- app/schema/request/staff/uploads.py | 34 +- app/schema/response/staff/upload_groups.py | 67 ++ app/schema/response/staff/uploads.py | 2 + app/service/upload_requests.py | 770 ++++++++++++++---- db/generated/models.py | 16 + db/generated/upload_request_groups.py | 310 +++++++ db/generated/upload_requests.py | 156 +++- db/queries/upload_request_groups.sql | 64 ++ db/queries/upload_requests.sql | 34 +- .../sql/down/add-upload-request-groups.sql | 10 + .../sql/up/add-upload-request-groups.sql | 28 + .../a7b4c2d1e9f0_add_upload_request_groups.py | 25 + 16 files changed, 1512 insertions(+), 166 deletions(-) create mode 100644 app/schema/response/staff/upload_groups.py create mode 100644 db/generated/upload_request_groups.py create mode 100644 db/queries/upload_request_groups.sql create mode 100644 migrations/sql/down/add-upload-request-groups.sql create mode 100644 migrations/sql/up/add-upload-request-groups.sql create mode 100644 migrations/versions/a7b4c2d1e9f0_add_upload_request_groups.py diff --git a/app/container.py b/app/container.py index d1f02a7..f20cc3b 100644 --- a/app/container.py +++ b/app/container.py @@ -19,6 +19,7 @@ from db.generated import staff_drive_connections as staff_drive_queries from db.generated import staff_notifications as staff_notification_queries from db.generated import stuff_user as staff_user_queries +from db.generated import upload_request_groups as upload_request_group_queries from db.generated import upload_request_photos as upload_request_photo_queries from db.generated import upload_requests as upload_request_queries from db.generated import user as user_queries @@ -39,6 +40,7 @@ def __init__( self.device_querier = device_queries.AsyncQuerier(conn) self.staff_user_querier = staff_user_queries.AsyncQuerier(conn) self.staff_drive_querier = staff_drive_queries.AsyncQuerier(conn) + self.upload_request_group_querier = upload_request_group_queries.AsyncQuerier(conn) 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) @@ -75,6 +77,7 @@ def __init__( self.staged_upload_storage_service = StagedUploadStorageService() self.upload_requests_service = UploadRequestsService( + upload_request_group_querier=self.upload_request_group_querier, upload_request_querier=self.upload_request_querier, upload_request_photo_querier=self.upload_request_photo_querier, photo_querier=self.photo_querier, diff --git a/app/infra/google_drive.py b/app/infra/google_drive.py index 0b32ad6..49a3d93 100644 --- a/app/infra/google_drive.py +++ b/app/infra/google_drive.py @@ -15,6 +15,7 @@ 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}" +GOOGLE_DRIVE_LIST_FILES_URL = "https://www.googleapis.com/drive/v3/files" @dataclass @@ -48,6 +49,8 @@ class GoogleDriveFileDownload: class GoogleDriveClient: + _drive_folder_mime_type = "application/vnd.google-apps.folder" + @staticmethod def _require_str(data: dict[str, object], key: str) -> str: value = data.get(key) @@ -181,6 +184,71 @@ async def download_file( ) return GoogleDriveFileDownload(metadata=metadata, content=content) + @staticmethod + async def list_folder_files( + *, + access_token: str, + folder_id: str, + ) -> list[GoogleDriveFileMetadata]: + files: list[GoogleDriveFileMetadata] = [] + next_page_token: str | None = None + + while True: + query_params = { + "q": f"'{folder_id}' in parents and trashed = false", + "fields": "nextPageToken,files(id,name,mimeType,size)", + "supportsAllDrives": "true", + "includeItemsFromAllDrives": "true", + "pageSize": "100", + } + if next_page_token is not None: + query_params["pageToken"] = next_page_token + + data = await GoogleDriveClient._get_json( + GOOGLE_DRIVE_LIST_FILES_URL, + headers={"Authorization": f"Bearer {access_token}"}, + query_params=query_params, + error_context="Google Drive folder listing request", + ) + + raw_files = data.get("files", []) + if not isinstance(raw_files, list): + raise AppException.bad_request("Google Drive folder listing response is invalid") + + for raw_file in raw_files: + if not isinstance(raw_file, dict): + raise AppException.bad_request("Google Drive folder entry is invalid") + metadata = GoogleDriveClient._file_metadata_from_dict(raw_file) + if metadata.mime_type == GoogleDriveClient._drive_folder_mime_type: + continue + files.append(metadata) + + next_page_token_raw = data.get("nextPageToken") + if next_page_token_raw is None: + break + if not isinstance(next_page_token_raw, str) or not next_page_token_raw: + raise AppException.bad_request("Google Drive next page token is invalid") + next_page_token = next_page_token_raw + + return files + + @staticmethod + def _file_metadata_from_dict(data: dict[str, object]) -> GoogleDriveFileMetadata: + size_raw = data.get("size", "0") + if not isinstance(size_raw, (str, int)): + raise AppException.bad_request("Google Drive file size is invalid") + try: + size_bytes = int(size_raw) + except (TypeError, ValueError) as exc: + raise AppException.bad_request("Google Drive file size is invalid") from exc + + return GoogleDriveFileMetadata( + id=GoogleDriveClient._require_str(data, "id"), + name=GoogleDriveClient._require_str(data, "name"), + mime_type=GoogleDriveClient._require_str(data, "mimeType"), + size_bytes=size_bytes, + ) + @staticmethod async def _post_form(url: str, payload: dict[str, str]) -> dict[str, object]: encoded = urllib.parse.urlencode(payload).encode("utf-8") diff --git a/app/infra/nats.py b/app/infra/nats.py index d2d2454..6495f22 100644 --- a/app/infra/nats.py +++ b/app/infra/nats.py @@ -15,6 +15,9 @@ class NatsSubjects(Enum): USER_SIGNUP = "user.signup" USER_LOGIN = "user.login" USER_LOGOUT = "user.logout" + STAFF_UPLOAD_GROUP_CREATED = "staff.upload_group.created" + STAFF_UPLOAD_GROUP_APPROVED = "staff.upload_group.approved" + STAFF_UPLOAD_GROUP_REJECTED = "staff.upload_group.rejected" STAFF_UPLOAD_REQUEST_CREATED = "staff.upload_request.created" STAFF_UPLOAD_REQUEST_APPROVED = "staff.upload_request.approved" STAFF_UPLOAD_REQUEST_REJECTED = "staff.upload_request.rejected" diff --git a/app/router/staff/uploads.py b/app/router/staff/uploads.py index cac4277..6a67e6c 100644 --- a/app/router/staff/uploads.py +++ b/app/router/staff/uploads.py @@ -13,29 +13,40 @@ CreateUploadRequestRequest, RejectUploadRequestRequest, ) +from app.schema.response.staff.upload_groups import ( + UploadRequestGroupListResponse, + UploadRequestGroupPhotoListResponse, + UploadRequestGroupSchema, +) from app.schema.response.staff.uploads import ( UploadRequestListResponse, UploadRequestPhotoListResponse, UploadRequestSchema, ) +from app.service.upload_requests import UploadRequestGroupDetails from db.generated.models import StaffUser, UploadRequestStatus router = APIRouter(prefix="/uploads", tags=["staff-uploads"]) -@router.post("/request", response_model=UploadRequestSchema) +@router.post("/request", response_model=UploadRequestSchema | UploadRequestGroupSchema) async def create_upload_request( req: CreateUploadRequestRequest, current_staff_user: StaffUser = Depends(get_current_staff_user), container: Container = Depends(get_container), -) -> UploadRequestSchema: - upload_request = await container.upload_requests_service.create_request( +) -> UploadRequestSchema | UploadRequestGroupSchema: + upload_result = await container.upload_requests_service.create_upload( event_id=req.event_id, + folder_id=req.folder_id, photos=req.to_inputs(), + visibility=req.visibility, + day_number=req.day_number, requested_by=current_staff_user, ) - return UploadRequestSchema.from_models(upload_request.request, upload_request.photos) + if isinstance(upload_result, UploadRequestGroupDetails): + return UploadRequestGroupSchema.from_details(upload_result) + return UploadRequestSchema.from_models(upload_result.request, upload_result.photos) @router.get("", response_model=UploadRequestListResponse) @@ -55,6 +66,75 @@ async def list_upload_requests( ) +@router.get("/groups", response_model=UploadRequestGroupListResponse) +async def list_upload_request_groups( + scope: Literal["my", "all"] = Query(default="my"), + status: UploadRequestStatus | None = Query(default=None), + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> UploadRequestGroupListResponse: + groups = await container.upload_requests_service.list_groups( + current_staff_user=current_staff_user, + scope=scope, + status=status.value if status is not None else None, + ) + return UploadRequestGroupListResponse.from_details_list(groups) + + +@router.get("/groups/{group_id}", response_model=UploadRequestGroupSchema) +async def get_upload_request_group( + group_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> UploadRequestGroupSchema: + group = await container.upload_requests_service.get_group_details( + group_id=group_id, + current_staff_user=current_staff_user, + ) + return UploadRequestGroupSchema.from_details(group) + + +@router.get("/groups/{group_id}/photos", response_model=UploadRequestGroupPhotoListResponse) +async def list_upload_request_group_photos( + group_id: UUID, + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> UploadRequestGroupPhotoListResponse: + photos = await container.upload_requests_service.list_group_photos( + group_id=group_id, + current_staff_user=current_staff_user, + ) + return UploadRequestGroupPhotoListResponse.from_photos(photos) + + +@router.post("/groups/{group_id}/approve", response_model=UploadRequestGroupSchema) +async def approve_upload_request_group( + group_id: UUID, + current_staff_user: StaffUser = Depends(require_multi_team_lead_staff), + container: Container = Depends(get_container), +) -> UploadRequestGroupSchema: + group = await container.upload_requests_service.approve_group( + group_id=group_id, + approved_by=current_staff_user, + ) + return UploadRequestGroupSchema.from_details(group) + + +@router.post("/groups/{group_id}/reject", response_model=UploadRequestGroupSchema) +async def reject_upload_request_group( + group_id: UUID, + req: RejectUploadRequestRequest, + current_staff_user: StaffUser = Depends(require_multi_team_lead_staff), + container: Container = Depends(get_container), +) -> UploadRequestGroupSchema: + group = await container.upload_requests_service.reject_group( + group_id=group_id, + approved_by=current_staff_user, + reason=req.reason, + ) + return UploadRequestGroupSchema.from_details(group) + + @router.get("/{request_id}", response_model=UploadRequestSchema) async def get_upload_request( request_id: UUID, diff --git a/app/schema/request/staff/uploads.py b/app/schema/request/staff/uploads.py index 191796d..a0b1c55 100644 --- a/app/schema/request/staff/uploads.py +++ b/app/schema/request/staff/uploads.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator from uuid import UUID from app.schema.dto.staff.uploads import UploadPhotoInput @@ -41,12 +41,42 @@ def to_input(self) -> UploadPhotoInput: class CreateUploadRequestRequest(BaseModel): event_id: UUID - photos: list[CreateUploadRequestPhotoRequest] = Field( + folder_id: str | None = Field(default=None, min_length=1, max_length=255) + photos: list[CreateUploadRequestPhotoRequest] | None = Field( + default=None, min_length=1, max_length=MAX_UPLOAD_BATCH_SIZE, ) + visibility: str = "private" + day_number: int | None = None + + @field_validator("folder_id", mode="before") + @classmethod + def _strip_optional_text(cls, value: object) -> object: + if isinstance(value, str): + stripped_value = value.strip() + return stripped_value or None + return value + + @field_validator("visibility") + @classmethod + def _validate_request_visibility(cls, value: str) -> str: + normalized_value = value.strip().lower() + if normalized_value not in {"private", "public"}: + raise ValueError("visibility must be either 'private' or 'public'") + return normalized_value + + @model_validator(mode="after") + def _validate_source(self) -> "CreateUploadRequestRequest": + has_folder = self.folder_id is not None + has_photos = self.photos is not None + if has_folder == has_photos: + raise ValueError("Exactly one of folder_id or photos must be provided") + return self def to_inputs(self) -> list[UploadPhotoInput]: + if self.photos is None: + return [] return [photo.to_input() for photo in self.photos] diff --git a/app/schema/response/staff/upload_groups.py b/app/schema/response/staff/upload_groups.py new file mode 100644 index 0000000..a08e7c2 --- /dev/null +++ b/app/schema/response/staff/upload_groups.py @@ -0,0 +1,67 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel + +from app.schema.response.staff.uploads import UploadRequestPhotoListResponse, UploadRequestSchema +from app.service.upload_requests import UploadRequestGroupDetails +from db.generated.models import UploadRequestPhoto + + +class UploadRequestGroupSchema(BaseModel): + id: UUID + event_id: UUID + folder_id: str + requested_by: UUID + approved_by: UUID | None + status: str + total_photo_count: int + batch_count: int + created_at: datetime + approved_at: datetime | None + rejection_reason: str | None + requests: list[UploadRequestSchema] + + @classmethod + def from_details( + cls, + details: UploadRequestGroupDetails, + ) -> "UploadRequestGroupSchema": + return cls( + id=details.group.id, + event_id=details.group.event_id, + folder_id=details.group.folder_id, + requested_by=details.group.requested_by, + approved_by=details.group.approved_by, + status=getattr(details.group.status, "value", str(details.group.status)), + total_photo_count=details.group.total_photo_count, + batch_count=details.group.batch_count, + created_at=details.group.created_at, + approved_at=details.group.approved_at, + rejection_reason=details.group.rejection_reason, + requests=[ + UploadRequestSchema.from_models(request_details.request, request_details.photos) + for request_details in details.requests + ], + ) + + +class UploadRequestGroupListResponse(BaseModel): + items: list[UploadRequestGroupSchema] + + @classmethod + def from_details_list( + cls, + details_list: list[UploadRequestGroupDetails], + ) -> "UploadRequestGroupListResponse": + return cls(items=[UploadRequestGroupSchema.from_details(details) for details in details_list]) + + +class UploadRequestGroupPhotoListResponse(UploadRequestPhotoListResponse): + @classmethod + def from_photos( + cls, + photos: list[UploadRequestPhoto], + ) -> "UploadRequestGroupPhotoListResponse": + base_response = UploadRequestPhotoListResponse.from_models(photos) + return cls(items=base_response.items) diff --git a/app/schema/response/staff/uploads.py b/app/schema/response/staff/uploads.py index 6b296cf..016a92e 100644 --- a/app/schema/response/staff/uploads.py +++ b/app/schema/response/staff/uploads.py @@ -38,6 +38,7 @@ def from_model( id: UUID event_id: UUID + group_id: UUID | None drive_file_id: str | None requested_by: UUID approved_by: UUID | None @@ -57,6 +58,7 @@ def from_models( return cls( id=upload_request.id, event_id=upload_request.event_id, + group_id=upload_request.group_id, drive_file_id=upload_request.drive_file_id, requested_by=upload_request.requested_by, approved_by=upload_request.approved_by, diff --git a/app/service/upload_requests.py b/app/service/upload_requests.py index 83732a2..11f076e 100644 --- a/app/service/upload_requests.py +++ b/app/service/upload_requests.py @@ -1,27 +1,33 @@ +from collections import defaultdict from collections.abc import Sequence from dataclasses import dataclass -from collections import defaultdict import json from typing import Literal import uuid +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.google_drive import ( + GoogleDriveClient, + GoogleDriveFileDownload, + GoogleDriveFileMetadata, +) from app.infra.nats import NatsClient, NatsSubjects -from sqlalchemy.exc import IntegrityError - from app.schema.dto.staff.uploads import UploadPhotoInput from app.service.staged_upload_storage import PreviewObject, StagedUploadStorageService from app.service.staff_drive import StaffDriveService from app.service.staff_notifications import StaffNotificationsService from db.generated import photos as photo_queries +from db.generated import upload_request_groups as upload_request_group_queries from db.generated import upload_request_photos as upload_request_photo_queries from db.generated import upload_requests as upload_request_queries from db.generated.models import ( StaffRole, StaffUser, UploadRequest, + UploadRequestGroup, UploadRequestPhoto, ) @@ -32,12 +38,20 @@ class UploadRequestDetails: photos: list[UploadRequestPhoto] +@dataclass +class UploadRequestGroupDetails: + group: UploadRequestGroup + requests: list[UploadRequestDetails] + + class UploadRequestsService: _allowed_mime_types = {"image/jpeg", "image/png", "image/webp"} _max_photo_size_bytes = 20 * 1024 * 1024 + _max_request_batch_size = 20 def __init__( self, + upload_request_group_querier: upload_request_group_queries.AsyncQuerier, upload_request_querier: upload_request_queries.AsyncQuerier, upload_request_photo_querier: upload_request_photo_queries.AsyncQuerier, photo_querier: photo_queries.AsyncQuerier, @@ -45,6 +59,7 @@ def __init__( staff_drive_service: StaffDriveService, staff_notifications_service: StaffNotificationsService, ): + self.upload_request_group_querier = upload_request_group_querier self.upload_request_querier = upload_request_querier self.upload_request_photo_querier = upload_request_photo_querier self.photo_querier = photo_querier @@ -60,6 +75,16 @@ def _status_value(status: object) -> str: def _role_value(role: object) -> str: return getattr(role, "value", str(role)) + @staticmethod + def _chunk_photo_inputs( + photos: Sequence[UploadPhotoInput], + chunk_size: int, + ) -> list[list[UploadPhotoInput]]: + return [ + list(photos[index : index + chunk_size]) + for index in range(0, len(photos), chunk_size) + ] + @staticmethod def _raise_integrity_error(exc: IntegrityError) -> None: orig = getattr(exc, "orig", None) @@ -79,11 +104,14 @@ def _validate_downloaded_photo(self, downloaded_photo: GoogleDriveFileDownload) if metadata.size_bytes <= 0 or metadata.size_bytes > self._max_photo_size_bytes: raise AppException.bad_request("Google Drive image exceeds maximum allowed size") + def _is_supported_image(self, metadata: GoogleDriveFileMetadata) -> bool: + return metadata.mime_type in self._allowed_mime_types and metadata.size_bytes > 0 + @staticmethod def _validate_create_request_inputs(photos: Sequence[UploadPhotoInput]) -> None: if not photos: raise AppException.bad_request("At least one photo is required") - if len(photos) > 20: + if len(photos) > UploadRequestsService._max_request_batch_size: raise AppException.bad_request("A batch can contain at most 20 photos") drive_file_ids = [photo.drive_file_id for photo in photos] @@ -100,6 +128,31 @@ async def _cleanup_created_photos(self, created_photos: Sequence[UploadRequestPh created_photo.staging_storage_key, ) + async def _cleanup_created_group( + self, + *, + upload_group_id: uuid.UUID, + created_requests: Sequence[UploadRequestDetails], + ) -> None: + for request_details in reversed(created_requests): + try: + await self.upload_request_querier.delete_upload_request(id=request_details.request.id) + except Exception as exc: + logger.warning( + "Failed to delete upload request %s during group cleanup: %s", + request_details.request.id, + exc, + ) + + try: + await self.upload_request_group_querier.delete_upload_request_group(id=upload_group_id) + except Exception as exc: + logger.warning( + "Failed to delete upload request group %s during cleanup: %s", + upload_group_id, + exc, + ) + async def _cleanup_finalized_objects(self, storage_keys: Sequence[str]) -> None: for storage_key in storage_keys: try: @@ -195,6 +248,148 @@ async def _create_staged_photo( return created_photo + async def _create_request_with_access_token( + self, + *, + event_id: uuid.UUID, + photos: Sequence[UploadPhotoInput], + requested_by: StaffUser, + access_token: str, + group_id: uuid.UUID | None = None, + publish_event: bool = True, + ) -> UploadRequestDetails: + self._validate_create_request_inputs(photos) + + try: + upload_request = await self.upload_request_querier.create_upload_request( + event_id=event_id, + group_id=group_id, + drive_file_id=None, + requested_by=requested_by.id, + photo_count=len(photos), + ) + except IntegrityError as exc: + self._raise_integrity_error(exc) + if upload_request is None: + raise AppException.internal_error("Failed to create upload request") + + created_photos: list[UploadRequestPhoto] = [] + try: + for photo in photos: + created_photos.append( + await self._create_staged_photo( + upload_request_id=upload_request.id, + photo=photo, + access_token=access_token, + ) + ) + except IntegrityError as exc: + await self._cleanup_created_photos(created_photos) + self._raise_integrity_error(exc) + except Exception: + await self._cleanup_created_photos(created_photos) + raise + + if publish_event: + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_REQUEST_CREATED, + payload={ + "upload_request_id": str(upload_request.id), + "event_id": str(upload_request.event_id), + "requested_by": str(requested_by.id), + "photo_count": upload_request.photo_count, + "group_id": str(group_id) if group_id is not None else None, + }, + ) + + return UploadRequestDetails(request=upload_request, photos=created_photos) + + async def _approve_request_without_side_effects( + self, + *, + request_id: uuid.UUID, + approved_by: StaffUser, + ) -> tuple[UploadRequest, list[UploadRequestPhoto], list[str]]: + existing = await self.upload_request_querier.get_upload_request_by_id(id=request_id) + if existing is None: + raise AppException.not_found("Upload request not found") + if self._status_value(existing.status) != "pending": + raise AppException.bad_request("Upload request is not pending") + + staged_photos = await self.list_request_photos(request_id) + if not staged_photos: + raise AppException.bad_request("No staged photos found for this upload request") + + finalized_storage_keys: list[str] = [] + try: + for staged_photo in staged_photos: + final_storage_key = await self.staged_upload_storage.promote_to_final( + event_id=existing.event_id, + photo_id=staged_photo.id, + file_name=staged_photo.file_name, + staging_storage_key=staged_photo.staging_storage_key, + ) + finalized_storage_keys.append(final_storage_key) + created_photo = await self.photo_querier.create_photo( + event_id=existing.event_id, + storage_key=final_storage_key, + taken_at=staged_photo.taken_at, + day_number=staged_photo.day_number, + visibility=staged_photo.visibility, + ) + if created_photo is None: + raise AppException.internal_error("Failed to finalize staged photo") + updated_photo = await self.upload_request_photo_querier.update_upload_request_photo_approval( + id=staged_photo.id, + status="approved", + final_storage_key=final_storage_key, + ) + if updated_photo is None: + raise AppException.internal_error("Failed to update staged photo approval state") + + upload_request = await self.upload_request_querier.approve_upload_request( + id=request_id, + approved_by=approved_by.id, + ) + if upload_request is None: + raise AppException.internal_error("Failed to approve upload request") + except Exception: + await self._cleanup_finalized_objects(finalized_storage_keys) + raise + + return upload_request, staged_photos, finalized_storage_keys + + async def _reject_request_without_side_effects( + self, + *, + request_id: uuid.UUID, + approved_by: StaffUser, + reason: str | None, + ) -> tuple[UploadRequest, list[UploadRequestPhoto], list[UploadRequestPhoto]]: + existing = await self.upload_request_querier.get_upload_request_by_id(id=request_id) + if existing is None: + raise AppException.not_found("Upload request not found") + if self._status_value(existing.status) != "pending": + raise AppException.bad_request("Upload request is not pending") + + upload_request = await self.upload_request_querier.reject_upload_request( + id=request_id, + approved_by=approved_by.id, + rejection_reason=reason, + ) + if upload_request is None: + raise AppException.internal_error("Failed to reject upload request") + + staged_photos = await self.list_request_photos(request_id) + rejected_photos: list[UploadRequestPhoto] = [] + async for staged_photo in self.upload_request_photo_querier.update_upload_request_photo_status_by_upload_request_id( + upload_request_id=request_id, + status="rejected", + ): + rejected_photos.append(staged_photo) + + return upload_request, rejected_photos, staged_photos + def _ensure_request_access( self, *, @@ -207,6 +402,38 @@ def _ensure_request_access( return raise AppException.forbidden("You are not allowed to access this upload request") + def _ensure_group_access( + self, + *, + current_staff_user: StaffUser, + upload_group: UploadRequestGroup, + ) -> None: + if upload_group.requested_by == current_staff_user.id: + return + if self._role_value(current_staff_user.role) == StaffRole.MULTI_TEAM_LEAD.value: + return + raise AppException.forbidden("You are not allowed to access this upload request group") + + def _ensure_group_is_pending( + self, + group: UploadRequestGroup, + ) -> None: + if self._status_value(group.status) != "pending": + raise AppException.bad_request("Upload request group is not pending") + + def _ensure_all_requests_are_pending( + self, + requests: Sequence[UploadRequestDetails], + ) -> None: + if not requests: + raise AppException.bad_request("No upload requests found for this group") + + for request_details in requests: + if self._status_value(request_details.request.status) != "pending": + raise AppException.bad_request( + "Upload request group contains non-pending requests" + ) + async def _publish_event( self, *, @@ -218,43 +445,29 @@ async def _publish_event( except Exception as exc: logger.warning("Failed to publish upload request event %s: %s", subject.value, exc) - async def get_request_details( - self, - *, - request_id: uuid.UUID, - current_staff_user: StaffUser, - ) -> UploadRequestDetails: - upload_request = await self.upload_request_querier.get_upload_request_by_id(id=request_id) - if upload_request is None: - raise AppException.not_found("Upload request not found") - self._ensure_request_access( - current_staff_user=current_staff_user, - upload_request=upload_request, - ) - return UploadRequestDetails( - request=upload_request, - photos=await self.list_request_photos(upload_request.id), - ) - - async def get_request_photo_preview( + async def create_upload( self, *, - request_id: uuid.UUID, - photo_id: uuid.UUID, - current_staff_user: StaffUser, - ) -> PreviewObject: - upload_request = await self.upload_request_querier.get_upload_request_by_id(id=request_id) - if upload_request is None: - raise AppException.not_found("Upload request not found") - self._ensure_request_access( - current_staff_user=current_staff_user, - upload_request=upload_request, + event_id: uuid.UUID, + folder_id: str | None, + photos: Sequence[UploadPhotoInput], + visibility: str, + day_number: int | None, + requested_by: StaffUser, + ) -> UploadRequestDetails | UploadRequestGroupDetails: + if folder_id is not None: + return await self.create_group_from_folder( + event_id=event_id, + folder_id=folder_id, + visibility=visibility, + day_number=day_number, + requested_by=requested_by, + ) + return await self.create_request( + event_id=event_id, + photos=photos, + requested_by=requested_by, ) - photo = await self.upload_request_photo_querier.get_upload_request_photo_by_id(id=photo_id) - if photo is None or photo.upload_request_id != request_id: - raise AppException.not_found("Upload request photo not found") - storage_key = photo.final_storage_key or photo.staging_storage_key - return await self.staged_upload_storage.get_preview(storage_key) async def create_request( self, @@ -263,52 +476,149 @@ async def create_request( photos: Sequence[UploadPhotoInput], requested_by: StaffUser, ) -> UploadRequestDetails: - self._validate_create_request_inputs(photos) + access_token = await self.staff_drive_service.get_access_token_for_staff_user( + requested_by.id + ) + return await self._create_request_with_access_token( + event_id=event_id, + photos=photos, + requested_by=requested_by, + access_token=access_token, + ) + async def create_group_from_folder( + self, + *, + event_id: uuid.UUID, + folder_id: str, + visibility: str, + day_number: int | None, + requested_by: StaffUser, + ) -> UploadRequestGroupDetails: access_token = await self.staff_drive_service.get_access_token_for_staff_user( requested_by.id ) + folder_files = await GoogleDriveClient.list_folder_files( + access_token=access_token, + folder_id=folder_id, + ) + folder_files = sorted(folder_files, key=lambda file: (file.name.lower(), file.id)) + photo_inputs = [ + UploadPhotoInput( + drive_file_id=file.id, + taken_at=None, + day_number=day_number, + visibility=visibility, + ) + for file in folder_files + if self._is_supported_image(file) + ] + if not photo_inputs: + raise AppException.bad_request( + "Selected Google Drive folder does not contain valid images" + ) + photo_batches = self._chunk_photo_inputs(photo_inputs, self._max_request_batch_size) try: - upload_request = await self.upload_request_querier.create_upload_request( + upload_group = await self.upload_request_group_querier.create_upload_request_group( event_id=event_id, - drive_file_id=None, + folder_id=folder_id, requested_by=requested_by.id, - photo_count=len(photos), + total_photo_count=len(photo_inputs), + batch_count=len(photo_batches), ) except IntegrityError as exc: self._raise_integrity_error(exc) - if upload_request is None: - raise AppException.internal_error("Failed to create upload request") + if upload_group is None: + raise AppException.internal_error("Failed to create upload request group") - created_photos: list[UploadRequestPhoto] = [] + created_requests: list[UploadRequestDetails] = [] try: - for photo in photos: - created_photos.append( - await self._create_staged_photo( - upload_request_id=upload_request.id, - photo=photo, + for batch in photo_batches: + created_requests.append( + await self._create_request_with_access_token( + event_id=event_id, + photos=batch, + requested_by=requested_by, access_token=access_token, + group_id=upload_group.id, + publish_event=False, ) ) - except IntegrityError as exc: - await self._cleanup_created_photos(created_photos) - self._raise_integrity_error(exc) except Exception: + created_photos = [ + photo + for request_details in created_requests + for photo in request_details.photos + ] await self._cleanup_created_photos(created_photos) + await self._cleanup_created_group( + upload_group_id=upload_group.id, + created_requests=created_requests, + ) raise + for request_details in created_requests: + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_REQUEST_CREATED, + payload={ + "upload_request_id": str(request_details.request.id), + "event_id": str(request_details.request.event_id), + "requested_by": str(requested_by.id), + "photo_count": request_details.request.photo_count, + "group_id": str(upload_group.id), + }, + ) + await self._publish_event( - subject=NatsSubjects.STAFF_UPLOAD_REQUEST_CREATED, + subject=NatsSubjects.STAFF_UPLOAD_GROUP_CREATED, payload={ - "upload_request_id": str(upload_request.id), - "event_id": str(upload_request.event_id), + "group_id": str(upload_group.id), + "event_id": str(upload_group.event_id), "requested_by": str(requested_by.id), - "photo_count": upload_request.photo_count, + "total_photo_count": upload_group.total_photo_count, + "batch_count": upload_group.batch_count, }, ) + return UploadRequestGroupDetails(group=upload_group, requests=created_requests) - return UploadRequestDetails(request=upload_request, photos=created_photos) + async def get_request_details( + self, + *, + request_id: uuid.UUID, + current_staff_user: StaffUser, + ) -> UploadRequestDetails: + upload_request = await self.upload_request_querier.get_upload_request_by_id(id=request_id) + if upload_request is None: + raise AppException.not_found("Upload request not found") + self._ensure_request_access( + current_staff_user=current_staff_user, + upload_request=upload_request, + ) + return UploadRequestDetails( + request=upload_request, + photos=await self.list_request_photos(upload_request.id), + ) + + async def get_request_photo_preview( + self, + *, + request_id: uuid.UUID, + photo_id: uuid.UUID, + current_staff_user: StaffUser, + ) -> PreviewObject: + upload_request = await self.upload_request_querier.get_upload_request_by_id(id=request_id) + if upload_request is None: + raise AppException.not_found("Upload request not found") + self._ensure_request_access( + current_staff_user=current_staff_user, + upload_request=upload_request, + ) + photo = await self.upload_request_photo_querier.get_upload_request_photo_by_id(id=photo_id) + if photo is None or photo.upload_request_id != request_id: + raise AppException.not_found("Upload request photo not found") + storage_key = photo.final_storage_key or photo.staging_storage_key + return await self.staged_upload_storage.get_preview(storage_key) async def list_requests( self, @@ -321,7 +631,6 @@ async def list_requests( raise AppException.forbidden("Multi team lead access required") requested_by = current_staff_user.id if scope == "my" else None - request_rows: list[UploadRequest] = [] async for upload_request in self.upload_request_querier.list_upload_requests( requested_by=requested_by, @@ -332,16 +641,13 @@ async def list_requests( photos_by_request_id = await self._list_request_photos_by_request_ids( [upload_request.id for upload_request in request_rows] ) - - requests: list[UploadRequestDetails] = [] - for upload_request in request_rows: - requests.append( - UploadRequestDetails( - request=upload_request, - photos=photos_by_request_id.get(upload_request.id, []), - ) + return [ + UploadRequestDetails( + request=upload_request, + photos=photos_by_request_id.get(upload_request.id, []), ) - return requests + for upload_request in request_rows + ] async def list_request_photos( self, @@ -354,55 +660,97 @@ async def list_request_photos( photos.append(photo) return photos - async def approve_request( + async def get_group_details( self, *, - request_id: uuid.UUID, - approved_by: StaffUser, - ) -> UploadRequestDetails: - existing = await self.upload_request_querier.get_upload_request_by_id(id=request_id) - if existing is None: - raise AppException.not_found("Upload request not found") - if self._status_value(existing.status) != "pending": - raise AppException.bad_request("Upload request is not pending") + group_id: uuid.UUID, + current_staff_user: StaffUser, + ) -> UploadRequestGroupDetails: + group = await self.upload_request_group_querier.get_upload_request_group_by_id(id=group_id) + if group is None: + raise AppException.not_found("Upload request group not found") + self._ensure_group_access( + current_staff_user=current_staff_user, + upload_group=group, + ) - staged_photos = await self.list_request_photos(request_id) - if not staged_photos: - raise AppException.bad_request("No staged photos found for this upload request") + requests: list[UploadRequest] = [] + async for upload_request in self.upload_request_querier.list_upload_requests_by_group_id( + group_id=group_id + ): + requests.append(upload_request) - finalized_storage_keys: list[str] = [] - try: - for staged_photo in staged_photos: - final_storage_key = await self.staged_upload_storage.promote_to_final( - event_id=existing.event_id, - photo_id=staged_photo.id, - file_name=staged_photo.file_name, - staging_storage_key=staged_photo.staging_storage_key, - ) - finalized_storage_keys.append(final_storage_key) - created_photo = await self.photo_querier.create_photo( - event_id=existing.event_id, - storage_key=final_storage_key, - taken_at=staged_photo.taken_at, - day_number=staged_photo.day_number, - visibility=staged_photo.visibility, + photos_by_request_id = await self._list_request_photos_by_request_ids( + [upload_request.id for upload_request in requests] + ) + return UploadRequestGroupDetails( + group=group, + requests=[ + UploadRequestDetails( + request=upload_request, + photos=photos_by_request_id.get(upload_request.id, []), ) - if created_photo is None: - raise AppException.internal_error("Failed to finalize staged photo") - updated_photo = await self.upload_request_photo_querier.update_upload_request_photo_approval( - id=staged_photo.id, - status="approved", - final_storage_key=final_storage_key, + for upload_request in requests + ], + ) + + async def list_groups( + self, + *, + current_staff_user: StaffUser, + scope: Literal["my", "all"], + status: str | None, + ) -> list[UploadRequestGroupDetails]: + if scope == "all" and self._role_value(current_staff_user.role) != StaffRole.MULTI_TEAM_LEAD.value: + raise AppException.forbidden("Multi team lead access required") + + requested_by = current_staff_user.id if scope == "my" else None + groups: list[UploadRequestGroup] = [] + async for group in self.upload_request_group_querier.list_upload_request_groups( + requested_by=requested_by, + status=status, + ): + groups.append(group) + + details: list[UploadRequestGroupDetails] = [] + for group in groups: + details.append( + await self.get_group_details( + group_id=group.id, + current_staff_user=current_staff_user, ) - if updated_photo is None: - raise AppException.internal_error("Failed to update staged photo approval state") + ) + return details - upload_request = await self.upload_request_querier.approve_upload_request( - id=request_id, - approved_by=approved_by.id, + async def list_group_photos( + self, + *, + group_id: uuid.UUID, + current_staff_user: StaffUser, + ) -> list[UploadRequestPhoto]: + group_details = await self.get_group_details( + group_id=group_id, + current_staff_user=current_staff_user, + ) + return [ + photo + for request_details in group_details.requests + for photo in request_details.photos + ] + + async def approve_request( + self, + *, + request_id: uuid.UUID, + approved_by: StaffUser, + ) -> UploadRequestDetails: + upload_request, staged_photos, finalized_storage_keys = ( + await self._approve_request_without_side_effects( + request_id=request_id, + approved_by=approved_by, ) - if upload_request is None: - raise AppException.internal_error("Failed to approve upload request") + ) + try: await self.staff_notifications_service.create_notification( staff_user_id=upload_request.requested_by, type="upload_request_approved", @@ -414,20 +762,20 @@ async def approve_request( "status": "approved", }, ) + await self._delete_staging_objects_best_effort(staged_photos) + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_REQUEST_APPROVED, + payload={ + "upload_request_id": str(upload_request.id), + "event_id": str(upload_request.event_id), + "approved_by": str(approved_by.id), + "photo_count": upload_request.photo_count, + }, + ) except Exception: await self._cleanup_finalized_objects(finalized_storage_keys) raise - await self._delete_staging_objects_best_effort(staged_photos) - await self._publish_event( - subject=NatsSubjects.STAFF_UPLOAD_REQUEST_APPROVED, - payload={ - "upload_request_id": str(upload_request.id), - "event_id": str(upload_request.event_id), - "approved_by": str(approved_by.id), - "photo_count": upload_request.photo_count, - }, - ) return UploadRequestDetails( request=upload_request, photos=await self.list_request_photos(request_id), @@ -440,28 +788,13 @@ async def reject_request( approved_by: StaffUser, reason: str | None, ) -> UploadRequestDetails: - existing = await self.upload_request_querier.get_upload_request_by_id(id=request_id) - if existing is None: - raise AppException.not_found("Upload request not found") - if self._status_value(existing.status) != "pending": - raise AppException.bad_request("Upload request is not pending") - - upload_request = await self.upload_request_querier.reject_upload_request( - id=request_id, - approved_by=approved_by.id, - rejection_reason=reason, + upload_request, rejected_photos, staged_photos = ( + await self._reject_request_without_side_effects( + request_id=request_id, + approved_by=approved_by, + reason=reason, + ) ) - if upload_request is None: - raise AppException.internal_error("Failed to reject upload request") - - staged_photos = await self.list_request_photos(request_id) - rejected_photos: list[UploadRequestPhoto] = [] - async for staged_photo in self.upload_request_photo_querier.update_upload_request_photo_status_by_upload_request_id( - upload_request_id=request_id, - status="rejected", - ): - rejected_photos.append(staged_photo) - await self.staff_notifications_service.create_notification( staff_user_id=upload_request.requested_by, type="upload_request_rejected", @@ -486,3 +819,158 @@ async def reject_request( ) await self._delete_staging_objects_best_effort(staged_photos) return UploadRequestDetails(request=upload_request, photos=rejected_photos) + + async def approve_group( + self, + *, + group_id: uuid.UUID, + approved_by: StaffUser, + ) -> UploadRequestGroupDetails: + group_details = await self.get_group_details( + group_id=group_id, + current_staff_user=approved_by, + ) + self._ensure_group_is_pending(group_details.group) + pending_requests = group_details.requests + self._ensure_all_requests_are_pending(pending_requests) + + approved_requests: list[UploadRequest] = [] + all_staged_photos: list[UploadRequestPhoto] = [] + finalized_storage_keys: list[str] = [] + try: + for request_details in pending_requests: + approved_request, staged_photos, request_storage_keys = ( + await self._approve_request_without_side_effects( + request_id=request_details.request.id, + approved_by=approved_by, + ) + ) + approved_requests.append(approved_request) + all_staged_photos.extend(staged_photos) + finalized_storage_keys.extend(request_storage_keys) + + upload_group = await self.upload_request_group_querier.approve_upload_request_group( + id=group_id, + approved_by=approved_by.id, + ) + if upload_group is None: + raise AppException.internal_error("Failed to approve upload request group") + + for approved_request in approved_requests: + await self.staff_notifications_service.create_notification( + staff_user_id=approved_request.requested_by, + type="upload_request_approved", + payload={ + "upload_request_id": str(approved_request.id), + "event_id": str(approved_request.event_id), + "photo_count": approved_request.photo_count, + "approved_by": str(approved_by.id), + "status": "approved", + }, + ) + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_REQUEST_APPROVED, + payload={ + "upload_request_id": str(approved_request.id), + "event_id": str(approved_request.event_id), + "approved_by": str(approved_by.id), + "photo_count": approved_request.photo_count, + }, + ) + + await self._delete_staging_objects_best_effort(all_staged_photos) + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_GROUP_APPROVED, + payload={ + "group_id": str(upload_group.id), + "event_id": str(upload_group.event_id), + "approved_by": str(approved_by.id), + "total_photo_count": upload_group.total_photo_count, + "batch_count": upload_group.batch_count, + }, + ) + except Exception: + await self._cleanup_finalized_objects(finalized_storage_keys) + raise + + return await self.get_group_details( + group_id=group_id, + current_staff_user=approved_by, + ) + + async def reject_group( + self, + *, + group_id: uuid.UUID, + approved_by: StaffUser, + reason: str | None, + ) -> UploadRequestGroupDetails: + group_details = await self.get_group_details( + group_id=group_id, + current_staff_user=approved_by, + ) + self._ensure_group_is_pending(group_details.group) + pending_requests = group_details.requests + self._ensure_all_requests_are_pending(pending_requests) + + rejected_requests: list[UploadRequest] = [] + all_staged_photos: list[UploadRequestPhoto] = [] + for request_details in pending_requests: + rejected_request, _rejected_photos, staged_photos = ( + await self._reject_request_without_side_effects( + request_id=request_details.request.id, + approved_by=approved_by, + reason=reason, + ) + ) + rejected_requests.append(rejected_request) + all_staged_photos.extend(staged_photos) + + upload_group = await self.upload_request_group_querier.reject_upload_request_group( + id=group_id, + approved_by=approved_by.id, + rejection_reason=reason, + ) + if upload_group is None: + raise AppException.internal_error("Failed to reject upload request group") + + for rejected_request in rejected_requests: + await self.staff_notifications_service.create_notification( + staff_user_id=rejected_request.requested_by, + type="upload_request_rejected", + payload={ + "upload_request_id": str(rejected_request.id), + "event_id": str(rejected_request.event_id), + "photo_count": rejected_request.photo_count, + "approved_by": str(approved_by.id), + "status": "rejected", + "reason": reason, + }, + ) + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_REQUEST_REJECTED, + payload={ + "upload_request_id": str(rejected_request.id), + "event_id": str(rejected_request.event_id), + "approved_by": str(approved_by.id), + "photo_count": rejected_request.photo_count, + "reason": reason, + }, + ) + + await self._delete_staging_objects_best_effort(all_staged_photos) + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_GROUP_REJECTED, + payload={ + "group_id": str(upload_group.id), + "event_id": str(upload_group.event_id), + "approved_by": str(approved_by.id), + "total_photo_count": upload_group.total_photo_count, + "batch_count": upload_group.batch_count, + "reason": reason, + }, + ) + return await self.get_group_details( + group_id=group_id, + current_staff_user=approved_by, + ) diff --git a/db/generated/models.py b/db/generated/models.py index 07b95b8..dd3ed0a 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -167,6 +167,7 @@ class StaffUser: class UploadRequest: id: uuid.UUID event_id: uuid.UUID + group_id: Optional[uuid.UUID] drive_file_id: Optional[str] requested_by: uuid.UUID approved_by: Optional[uuid.UUID] @@ -177,6 +178,21 @@ class UploadRequest: rejection_reason: Optional[str] +@dataclasses.dataclass() +class UploadRequestGroup: + id: uuid.UUID + event_id: uuid.UUID + folder_id: str + requested_by: uuid.UUID + approved_by: Optional[uuid.UUID] + status: Any + total_photo_count: int + batch_count: int + created_at: datetime.datetime + approved_at: Optional[datetime.datetime] + rejection_reason: Optional[str] + + @dataclasses.dataclass() class UploadRequestPhoto: id: uuid.UUID diff --git a/db/generated/upload_request_groups.py b/db/generated/upload_request_groups.py new file mode 100644 index 0000000..736c260 --- /dev/null +++ b/db/generated/upload_request_groups.py @@ -0,0 +1,310 @@ +# Code generated manually to match the sqlc async querier style used in the repo. +from typing import AsyncIterator, Optional +import uuid + +import sqlalchemy +import sqlalchemy.ext.asyncio + +from . import models + + +CREATE_UPLOAD_REQUEST_GROUP = """-- name: create_upload_request_group \\:one +INSERT INTO upload_request_groups ( + event_id, + folder_id, + requested_by, + total_photo_count, + batch_count +) VALUES ( + :p1, :p2, :p3, :p4, :p5 +) +RETURNING + id, + event_id, + folder_id, + requested_by, + approved_by, + status, + total_photo_count, + batch_count, + created_at, + approved_at, + rejection_reason +""" + + +GET_UPLOAD_REQUEST_GROUP_BY_ID = """-- name: get_upload_request_group_by_id \\:one +SELECT + id, + event_id, + folder_id, + requested_by, + approved_by, + status, + total_photo_count, + batch_count, + created_at, + approved_at, + rejection_reason +FROM upload_request_groups +WHERE id = :p1 +""" + + +LIST_UPLOAD_REQUEST_GROUPS = """-- name: list_upload_request_groups \\:many +SELECT + id, + event_id, + folder_id, + requested_by, + approved_by, + status, + total_photo_count, + batch_count, + created_at, + approved_at, + rejection_reason +FROM upload_request_groups +ORDER BY created_at DESC +""" + + +LIST_UPLOAD_REQUEST_GROUPS_BY_STATUS = """-- name: list_upload_request_groups_by_status \\:many +SELECT + id, + event_id, + folder_id, + requested_by, + approved_by, + status, + total_photo_count, + batch_count, + created_at, + approved_at, + rejection_reason +FROM upload_request_groups +WHERE status = :p1 +ORDER BY created_at DESC +""" + + +LIST_UPLOAD_REQUEST_GROUPS_BY_REQUESTER = """-- name: list_upload_request_groups_by_requester \\:many +SELECT + id, + event_id, + folder_id, + requested_by, + approved_by, + status, + total_photo_count, + batch_count, + created_at, + approved_at, + rejection_reason +FROM upload_request_groups +WHERE requested_by = :p1 +ORDER BY created_at DESC +""" + + +LIST_UPLOAD_REQUEST_GROUPS_BY_REQUESTER_AND_STATUS = """-- name: list_upload_request_groups_by_requester_and_status \\:many +SELECT + id, + event_id, + folder_id, + requested_by, + approved_by, + status, + total_photo_count, + batch_count, + created_at, + approved_at, + rejection_reason +FROM upload_request_groups +WHERE requested_by = :p1 + AND status = :p2 +ORDER BY created_at DESC +""" + + +APPROVE_UPLOAD_REQUEST_GROUP = """-- name: approve_upload_request_group \\:one +UPDATE upload_request_groups +SET status = 'approved', + approved_by = :p2, + approved_at = NOW(), + rejection_reason = NULL +WHERE id = :p1 + AND status = 'pending' +RETURNING + id, + event_id, + folder_id, + requested_by, + approved_by, + status, + total_photo_count, + batch_count, + created_at, + approved_at, + rejection_reason +""" + + +REJECT_UPLOAD_REQUEST_GROUP = """-- name: reject_upload_request_group \\:one +UPDATE upload_request_groups +SET status = 'rejected', + approved_by = :p2, + approved_at = NOW(), + rejection_reason = :p3 +WHERE id = :p1 + AND status = 'pending' +RETURNING + id, + event_id, + folder_id, + requested_by, + approved_by, + status, + total_photo_count, + batch_count, + created_at, + approved_at, + rejection_reason +""" + + +DELETE_UPLOAD_REQUEST_GROUP = """-- name: delete_upload_request_group \\:exec +DELETE FROM upload_request_groups +WHERE id = :p1 +""" + + +class AsyncQuerier: + def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): + self._conn = conn + + async def create_upload_request_group( + self, + *, + event_id: uuid.UUID, + folder_id: str, + requested_by: uuid.UUID, + total_photo_count: int, + batch_count: int, + ) -> Optional[models.UploadRequestGroup]: + row = ( + await self._conn.execute( + sqlalchemy.text(CREATE_UPLOAD_REQUEST_GROUP), + { + "p1": event_id, + "p2": folder_id, + "p3": requested_by, + "p4": total_photo_count, + "p5": batch_count, + }, + ) + ).first() + if row is None: + return None + return _row_to_upload_request_group(row) + + async def get_upload_request_group_by_id( + self, + *, + id: uuid.UUID, + ) -> Optional[models.UploadRequestGroup]: + row = ( + await self._conn.execute( + sqlalchemy.text(GET_UPLOAD_REQUEST_GROUP_BY_ID), + {"p1": id}, + ) + ).first() + if row is None: + return None + return _row_to_upload_request_group(row) + + async def list_upload_request_groups( + self, + *, + requested_by: uuid.UUID | None, + status: str | None, + ) -> AsyncIterator[models.UploadRequestGroup]: + if requested_by is None and status is None: + statement = LIST_UPLOAD_REQUEST_GROUPS + params: dict[str, object] = {} + elif requested_by is None: + statement = LIST_UPLOAD_REQUEST_GROUPS_BY_STATUS + params = {"p1": status} + elif status is None: + statement = LIST_UPLOAD_REQUEST_GROUPS_BY_REQUESTER + params = {"p1": requested_by} + else: + statement = LIST_UPLOAD_REQUEST_GROUPS_BY_REQUESTER_AND_STATUS + params = {"p1": requested_by, "p2": status} + result = await self._conn.stream( + sqlalchemy.text(statement), + params, + ) + async for row in result: + yield _row_to_upload_request_group(row) + + async def approve_upload_request_group( + self, + *, + id: uuid.UUID, + approved_by: uuid.UUID, + ) -> Optional[models.UploadRequestGroup]: + row = ( + await self._conn.execute( + sqlalchemy.text(APPROVE_UPLOAD_REQUEST_GROUP), + {"p1": id, "p2": approved_by}, + ) + ).first() + if row is None: + return None + return _row_to_upload_request_group(row) + + async def reject_upload_request_group( + self, + *, + id: uuid.UUID, + approved_by: uuid.UUID, + rejection_reason: str | None, + ) -> Optional[models.UploadRequestGroup]: + row = ( + await self._conn.execute( + sqlalchemy.text(REJECT_UPLOAD_REQUEST_GROUP), + {"p1": id, "p2": approved_by, "p3": rejection_reason}, + ) + ).first() + if row is None: + return None + return _row_to_upload_request_group(row) + + async def delete_upload_request_group( + self, + *, + id: uuid.UUID, + ) -> None: + await self._conn.execute( + sqlalchemy.text(DELETE_UPLOAD_REQUEST_GROUP), + {"p1": id}, + ) + + +def _row_to_upload_request_group( + row: sqlalchemy.Row[tuple[object, ...]], +) -> models.UploadRequestGroup: + return models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + ) diff --git a/db/generated/upload_requests.py b/db/generated/upload_requests.py index ffddacd..7c40b5f 100644 --- a/db/generated/upload_requests.py +++ b/db/generated/upload_requests.py @@ -11,15 +11,17 @@ CREATE_UPLOAD_REQUEST = """-- name: create_upload_request \\:one INSERT INTO upload_requests ( event_id, + group_id, drive_file_id, requested_by, photo_count ) VALUES ( - :p1, :p2, :p3, :p4 + :p1, :p2, :p3, :p4, :p5 ) RETURNING id, event_id, + group_id, drive_file_id, requested_by, approved_by, @@ -35,6 +37,7 @@ SELECT id, event_id, + group_id, drive_file_id, requested_by, approved_by, @@ -48,10 +51,87 @@ """ +LIST_UPLOAD_REQUESTS_BY_GROUP_ID = """-- name: list_upload_requests_by_group_id \\:many +SELECT + id, + event_id, + group_id, + drive_file_id, + requested_by, + approved_by, + status, + photo_count, + created_at, + approved_at, + rejection_reason +FROM upload_requests +WHERE group_id = :p1 +ORDER BY created_at ASC +""" + + +LIST_UPLOAD_REQUESTS = """-- name: list_upload_requests \\:many +SELECT + id, + event_id, + group_id, + drive_file_id, + requested_by, + approved_by, + status, + photo_count, + created_at, + approved_at, + rejection_reason +FROM upload_requests +ORDER BY created_at DESC +""" + + +LIST_UPLOAD_REQUESTS_BY_STATUS = """-- name: list_upload_requests_by_status \\:many +SELECT + id, + event_id, + group_id, + drive_file_id, + requested_by, + approved_by, + status, + photo_count, + created_at, + approved_at, + rejection_reason +FROM upload_requests +WHERE status = :p1 +ORDER BY created_at DESC +""" + + +LIST_UPLOAD_REQUESTS_BY_REQUESTER_AND_STATUS = """-- name: list_upload_requests_by_requester_and_status \\:many +SELECT + id, + event_id, + group_id, + drive_file_id, + requested_by, + approved_by, + status, + photo_count, + created_at, + approved_at, + rejection_reason +FROM upload_requests +WHERE requested_by = :p1 + AND status = :p2 +ORDER BY created_at DESC +""" + + LIST_UPLOAD_REQUESTS_BY_REQUESTER = """-- name: list_upload_requests_by_requester \\:many SELECT id, event_id, + group_id, drive_file_id, requested_by, approved_by, @@ -61,8 +141,7 @@ approved_at, rejection_reason FROM upload_requests -WHERE (:p1 IS NULL OR requested_by = :p1) - AND (:p2::upload_request_status IS NULL OR status = :p2::upload_request_status) +WHERE requested_by = :p1 ORDER BY created_at DESC """ @@ -78,6 +157,7 @@ RETURNING id, event_id, + group_id, drive_file_id, requested_by, approved_by, @@ -100,6 +180,7 @@ RETURNING id, event_id, + group_id, drive_file_id, requested_by, approved_by, @@ -111,6 +192,12 @@ """ +DELETE_UPLOAD_REQUEST = """-- name: delete_upload_request \\:exec +DELETE FROM upload_requests +WHERE id = :p1 +""" + + class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn @@ -119,6 +206,7 @@ async def create_upload_request( self, *, event_id: uuid.UUID, + group_id: uuid.UUID | None, drive_file_id: str | None, requested_by: uuid.UUID, photo_count: int, @@ -128,9 +216,10 @@ async def create_upload_request( sqlalchemy.text(CREATE_UPLOAD_REQUEST), { "p1": event_id, - "p2": drive_file_id, - "p3": requested_by, - "p4": photo_count, + "p2": group_id, + "p3": drive_file_id, + "p4": requested_by, + "p5": photo_count, }, ) ).first() @@ -138,6 +227,18 @@ async def create_upload_request( return None return _row_to_upload_request(row) + async def list_upload_requests_by_group_id( + self, + *, + group_id: uuid.UUID, + ) -> AsyncIterator[models.UploadRequest]: + result = await self._conn.stream( + sqlalchemy.text(LIST_UPLOAD_REQUESTS_BY_GROUP_ID), + {"p1": group_id}, + ) + async for row in result: + yield _row_to_upload_request(row) + async def get_upload_request_by_id( self, *, @@ -159,9 +260,21 @@ async def list_upload_requests( requested_by: uuid.UUID | None, status: str | None, ) -> AsyncIterator[models.UploadRequest]: + if requested_by is None and status is None: + statement = LIST_UPLOAD_REQUESTS + params: dict[str, object] = {} + elif requested_by is None: + statement = LIST_UPLOAD_REQUESTS_BY_STATUS + params = {"p1": status} + elif status is None: + statement = LIST_UPLOAD_REQUESTS_BY_REQUESTER + params = {"p1": requested_by} + else: + statement = LIST_UPLOAD_REQUESTS_BY_REQUESTER_AND_STATUS + params = {"p1": requested_by, "p2": status} result = await self._conn.stream( - sqlalchemy.text(LIST_UPLOAD_REQUESTS_BY_REQUESTER), - {"p1": requested_by, "p2": status}, + sqlalchemy.text(statement), + params, ) async for row in result: yield _row_to_upload_request(row) @@ -199,17 +312,28 @@ async def reject_upload_request( return None return _row_to_upload_request(row) + async def delete_upload_request( + self, + *, + id: uuid.UUID, + ) -> None: + await self._conn.execute( + sqlalchemy.text(DELETE_UPLOAD_REQUEST), + {"p1": id}, + ) + def _row_to_upload_request(row: sqlalchemy.Row[tuple[object, ...]]) -> models.UploadRequest: return models.UploadRequest( id=row[0], event_id=row[1], - drive_file_id=row[2], - requested_by=row[3], - approved_by=row[4], - status=row[5], - photo_count=row[6], - created_at=row[7], - approved_at=row[8], - rejection_reason=row[9], + group_id=row[2], + drive_file_id=row[3], + requested_by=row[4], + approved_by=row[5], + status=row[6], + photo_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], ) diff --git a/db/queries/upload_request_groups.sql b/db/queries/upload_request_groups.sql new file mode 100644 index 0000000..7dd1aeb --- /dev/null +++ b/db/queries/upload_request_groups.sql @@ -0,0 +1,64 @@ +-- name: CreateUploadRequestGroup :one +INSERT INTO upload_request_groups ( + event_id, + folder_id, + requested_by, + total_photo_count, + batch_count +) VALUES ( + $1, $2, $3, $4, $5 +) +RETURNING *; + +-- name: GetUploadRequestGroupByID :one +SELECT * +FROM upload_request_groups +WHERE id = $1; + +-- name: ListUploadRequestGroups :many +SELECT * +FROM upload_request_groups +ORDER BY created_at DESC; + +-- name: ListUploadRequestGroupsByStatus :many +SELECT * +FROM upload_request_groups +WHERE status = $1 +ORDER BY created_at DESC; + +-- name: ListUploadRequestGroupsByRequester :many +SELECT * +FROM upload_request_groups +WHERE requested_by = $1 +ORDER BY created_at DESC; + +-- name: ListUploadRequestGroupsByRequesterAndStatus :many +SELECT * +FROM upload_request_groups +WHERE requested_by = $1 + AND status = $2 +ORDER BY created_at DESC; + +-- name: ApproveUploadRequestGroup :one +UPDATE upload_request_groups +SET status = 'approved', + approved_by = $2, + approved_at = NOW(), + rejection_reason = NULL +WHERE id = $1 + AND status = 'pending' +RETURNING *; + +-- name: RejectUploadRequestGroup :one +UPDATE upload_request_groups +SET status = 'rejected', + approved_by = $2, + approved_at = NOW(), + rejection_reason = $3 +WHERE id = $1 + AND status = 'pending' +RETURNING *; + +-- name: DeleteUploadRequestGroup :exec +DELETE FROM upload_request_groups +WHERE id = $1; diff --git a/db/queries/upload_requests.sql b/db/queries/upload_requests.sql index 72d9e7d..043f641 100644 --- a/db/queries/upload_requests.sql +++ b/db/queries/upload_requests.sql @@ -1,11 +1,12 @@ -- name: CreateUploadRequest :one INSERT INTO upload_requests ( event_id, + group_id, drive_file_id, requested_by, photo_count ) VALUES ( - $1, $2, $3, $4 + $1, $2, $3, $4, $5 ) RETURNING *; @@ -14,11 +15,34 @@ SELECT * FROM upload_requests WHERE id = $1; +-- name: ListUploadRequestsByGroupID :many +SELECT * +FROM upload_requests +WHERE group_id = $1 +ORDER BY created_at ASC; + -- name: ListUploadRequests :many SELECT * FROM upload_requests -WHERE ($1::uuid IS NULL OR requested_by = $1) - AND ($2::upload_request_status IS NULL OR status = $2) +ORDER BY created_at DESC; + +-- name: ListUploadRequestsByStatus :many +SELECT * +FROM upload_requests +WHERE status = $1 +ORDER BY created_at DESC; + +-- name: ListUploadRequestsByRequester :many +SELECT * +FROM upload_requests +WHERE requested_by = $1 +ORDER BY created_at DESC; + +-- name: ListUploadRequestsByRequesterAndStatus :many +SELECT * +FROM upload_requests +WHERE requested_by = $1 + AND status = $2 ORDER BY created_at DESC; -- name: ApproveUploadRequest :one @@ -40,3 +64,7 @@ SET status = 'rejected', WHERE id = $1 AND status = 'pending' RETURNING *; + +-- name: DeleteUploadRequest :exec +DELETE FROM upload_requests +WHERE id = $1; diff --git a/migrations/sql/down/add-upload-request-groups.sql b/migrations/sql/down/add-upload-request-groups.sql new file mode 100644 index 0000000..53c93f9 --- /dev/null +++ b/migrations/sql/down/add-upload-request-groups.sql @@ -0,0 +1,10 @@ +DROP INDEX IF EXISTS idx_upload_requests_group_id; + +ALTER TABLE upload_requests + DROP COLUMN IF EXISTS group_id; + +DROP INDEX IF EXISTS idx_upload_request_groups_status; +DROP INDEX IF EXISTS idx_upload_request_groups_requested_by; +DROP INDEX IF EXISTS idx_upload_request_groups_event_id; + +DROP TABLE IF EXISTS upload_request_groups; diff --git a/migrations/sql/up/add-upload-request-groups.sql b/migrations/sql/up/add-upload-request-groups.sql new file mode 100644 index 0000000..03ae017 --- /dev/null +++ b/migrations/sql/up/add-upload-request-groups.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS upload_request_groups ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + folder_id TEXT NOT NULL, + requested_by UUID NOT NULL REFERENCES staff_users(id) ON DELETE RESTRICT, + approved_by UUID REFERENCES staff_users(id) ON DELETE SET NULL, + status upload_request_status NOT NULL DEFAULT 'pending', + total_photo_count INT NOT NULL DEFAULT 0, + batch_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + approved_at TIMESTAMPTZ, + rejection_reason TEXT +); + +CREATE INDEX IF NOT EXISTS idx_upload_request_groups_event_id +ON upload_request_groups(event_id); + +CREATE INDEX IF NOT EXISTS idx_upload_request_groups_requested_by +ON upload_request_groups(requested_by); + +CREATE INDEX IF NOT EXISTS idx_upload_request_groups_status +ON upload_request_groups(status); + +ALTER TABLE upload_requests + ADD COLUMN IF NOT EXISTS group_id UUID REFERENCES upload_request_groups(id) ON DELETE CASCADE; + +CREATE INDEX IF NOT EXISTS idx_upload_requests_group_id +ON upload_requests(group_id); diff --git a/migrations/versions/a7b4c2d1e9f0_add_upload_request_groups.py b/migrations/versions/a7b4c2d1e9f0_add_upload_request_groups.py new file mode 100644 index 0000000..12f0008 --- /dev/null +++ b/migrations/versions/a7b4c2d1e9f0_add_upload_request_groups.py @@ -0,0 +1,25 @@ +"""add_upload_request_groups + +Revision ID: a7b4c2d1e9f0 +Revises: c3b8d0f1e2a4 +Create Date: 2026-03-25 00:10:00.000000 + +""" + +from typing import Sequence, Union + +from migrations.helper import run_sql_down, run_sql_up + + +revision: str = "a7b4c2d1e9f0" +down_revision: Union[str, Sequence[str], None] = "c3b8d0f1e2a4" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + run_sql_up("add-upload-request-groups") + + +def downgrade() -> None: + run_sql_down("add-upload-request-groups") From ebc2e7e41c257b8e4c7885e84153538f43da4b8c Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 25 Mar 2026 23:38:47 +0100 Subject: [PATCH 26/26] Fix mypy issues --- app/service/upload_requests.py | 12 +++++++----- app/worker/notification/firebase.py | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/service/upload_requests.py b/app/service/upload_requests.py index 11f076e..1a49ac8 100644 --- a/app/service/upload_requests.py +++ b/app/service/upload_requests.py @@ -331,11 +331,13 @@ async def _approve_request_without_side_effects( ) finalized_storage_keys.append(final_storage_key) created_photo = await self.photo_querier.create_photo( - event_id=existing.event_id, - storage_key=final_storage_key, - taken_at=staged_photo.taken_at, - day_number=staged_photo.day_number, - visibility=staged_photo.visibility, + photo_queries.CreatePhotoParams( + event_id=existing.event_id, + storage_key=final_storage_key, + taken_at=staged_photo.taken_at, + day_number=staged_photo.day_number, + visibility=staged_photo.visibility, + ) ) if created_photo is None: raise AppException.internal_error("Failed to finalize staged photo") diff --git a/app/worker/notification/firebase.py b/app/worker/notification/firebase.py index ffc2360..c490f3b 100644 --- a/app/worker/notification/firebase.py +++ b/app/worker/notification/firebase.py @@ -2,9 +2,9 @@ from typing import cast # pyright: ignore[reportMissingTypeStubs] -import firebase_admin # type: ignore[import-untyped] +import firebase_admin # type: ignore[import-not-found,import-untyped] # pyright: ignore[reportMissingTypeStubs] -from firebase_admin import credentials, messaging # type: ignore[import-untyped] +from firebase_admin import credentials, messaging # type: ignore[import-not-found,import-untyped] from app.core.config import settings from app.core.logger import logger