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/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 5f14801..d946aae 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -38,22 +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 }} - cache-from: type=gha - cache-to: type=gha,mode=max + tags: | + ${{ env.IMAGE_NAME }}:latest + ${{ env.IMAGE_NAME }}:${{ github.sha }} diff --git a/.gitignore b/.gitignore index 5cc1fe4..5593ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ db/schema.sql multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json db.txt +.venv +multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json diff --git a/app/container.py b/app/container.py index fd94a4f..4d08b1f 100644 --- a/app/container.py +++ b/app/container.py @@ -13,15 +13,21 @@ from app.service.staff_user import StaffUserService from app.service.audit import AuditService +from app.service.photo_approval import PhotoApprovalService +from app.service.user_photo import UserPhotoService from app.service.upload_requests import UploadRequestsService 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_approvals as photo_approval_queries +from db.generated import processing_jobs as processing_job_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 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 @@ -51,9 +57,13 @@ 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) + self.photo_approval_querier = photo_approval_queries.AsyncQuerier(conn) + self.photo_face_querier = photo_face_queries.AsyncQuerier(conn) + self.processing_job_querier = processing_job_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) @@ -93,13 +103,20 @@ def __init__( ) self.staged_upload_storage_service = StagedUploadStorageService() + self.audit_service = AuditService( + audit_querier=self.audit_querier, + user_querier=self.user_querier, + ) + 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, staged_upload_storage=self.staged_upload_storage_service, staff_drive_service=self.staff_drive_service, staff_notifications_service=self.staff_notifications_service, + audit_service=self.audit_service, ) notification_queue = NotificationQueue(settings=NotifSetting) @@ -107,15 +124,10 @@ def __init__( self.user_notifications_service = UserNotificationService( notification_querier=self.notification_querier, notification_queue=notification_queue, - ) - - self.audit_service = AuditService( - audit_querier=self.audit_querier, - user_querier=self.user_querier, + device_querier=self.device_querier, ) self.staff_user_service = StaffUserService() - self.staff_user_service.init( staff_user_querier=self.staff_user_querier,) @@ -124,6 +136,20 @@ def __init__( p_querier=self.participant_querier, ) + self.photo_approval_service = PhotoApprovalService( + photo_approval_querier=self.photo_approval_querier, + photo_querier=self.photo_querier, + storage_service=self.staged_upload_storage_service, + audit_service=self.audit_service, + ) + + self.user_photo_service = UserPhotoService( + photo_querier=self.photo_querier, + photo_face_querier=self.photo_face_querier, + photo_approval_querier=self.photo_approval_querier, + staff_drive_service=self.staff_drive_service, + ) + async def get_container( conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db), ) -> Container: diff --git a/app/core/config.py b/app/core/config.py index 0fc3d9d..9b72a6c 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,13 +17,13 @@ class Settings(BaseSettings): NATS_HOST: str NATS_PASSWORD: str NATS_USER: str - - # MinIO MINIO_API_PORT: int 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 @@ -35,13 +36,22 @@ 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" 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 = "" @@ -53,9 +63,24 @@ 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 value is None: + return True + 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 + return value settings = Settings() # type: ignore diff --git a/app/core/constant.py b/app/core/constant.py index 0cae9dc..4cde1e0 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -9,6 +9,12 @@ 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" +UPLOAD_GROUP_IMPORT_SUBJECT = "staff.upload_group.import.requested" +UPLOAD_GROUP_IMPORT_STREAM = "staff-upload-group-import" +UPLOAD_GROUP_IMPORT_DURABLE_NAME = "staff-upload-group-import-worker" class AuditEventType(str, Enum): @@ -18,7 +24,8 @@ class AuditEventType(str, Enum): UPLOAD_REQUEST_CREATED = "upload_request.created" UPLOAD_REQUEST_APPROVED = "upload_request.approved" UPLOAD_REQUEST_REJECTED = "upload_request.rejected" - + PHOTO_PROCESSED = "photo.processed" + PHOTO_APPROVAL_DECIDED = "photo_approval.decided" IMAGE_ALLOWED_TYPES = { @@ -28,6 +35,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/deps/token_auth.py b/app/deps/token_auth.py index c5fe522..a7eff17 100644 --- a/app/deps/token_auth.py +++ b/app/deps/token_auth.py @@ -43,6 +43,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/infra/google_drive.py b/app/infra/google_drive.py index 0b32ad6..f25c4ef 100644 --- a/app/infra/google_drive.py +++ b/app/infra/google_drive.py @@ -9,12 +9,13 @@ from app.core.exceptions import AppException from app.core.config import settings - - -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}" +from app.core.constant import ( + GOOGLE_AUTH_URL, + GOOGLE_DRIVE_FILES_URL, + GOOGLE_TOKEN_URL, + GOOGLE_USERINFO_URL, +) +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) @@ -118,6 +121,31 @@ async def exchange_code(code: str) -> GoogleTokenResponse: or "Bearer", ) + @staticmethod + async def refresh_access_token(refresh_token: str) -> GoogleTokenResponse: + GoogleDriveClient.validate_settings() + payload = { + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + "refresh_token": refresh_token, + "grant_type": "refresh_token", + } + data = await GoogleDriveClient._post_form(GOOGLE_TOKEN_URL, payload) + expires_at = None + expires_in = data.get("expires_in") + if isinstance(expires_in, int): + expires_at = datetime.now(timezone.utc) + timedelta(seconds=expires_in) + + return GoogleTokenResponse( + access_token=GoogleDriveClient._require_str(data, "access_token"), + refresh_token=GoogleDriveClient._optional_str(data, "refresh_token"), + expires_at=expires_at, + scope=GoogleDriveClient._optional_str(data, "scope") + or settings.GOOGLE_OAUTH_SCOPES, + token_type=GoogleDriveClient._optional_str(data, "token_type") + or "Bearer", + ) + @staticmethod async def get_user_info(access_token: str) -> GoogleUserInfo: data = await GoogleDriveClient._get_json( @@ -181,6 +209,167 @@ 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 + async def list_folder_contents( + *, + access_token: str, + folder_id: str | None = None, + ) -> list[GoogleDriveFileMetadata]: + """Like list_folder_files but includes folders. folder_id=None means root.""" + parent = folder_id or "root" + items: list[GoogleDriveFileMetadata] = [] + next_page_token: str | None = None + + while True: + query_params = { + "q": f"'{parent}' 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 browse 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): + continue + items.append(GoogleDriveClient._file_metadata_from_dict(raw_file)) + + next_page_token_raw = data.get("nextPageToken") + if not isinstance(next_page_token_raw, str) or not next_page_token_raw: + break + next_page_token = next_page_token_raw + + return items + + @staticmethod + async def search_files( + *, + access_token: str, + query: str, + file_type: str | None = None, + ) -> list[GoogleDriveFileMetadata]: + """Search Drive by name. file_type can be 'folder' or 'image'.""" + q_parts = [f"name contains '{query}'", "trashed = false"] + if file_type == "folder": + q_parts.append(f"mimeType = '{GoogleDriveClient._drive_folder_mime_type}'") + elif file_type == "image": + q_parts.append("mimeType contains 'image/'") + + items: list[GoogleDriveFileMetadata] = [] + next_page_token: str | None = None + + while True: + query_params = { + "q": " and ".join(q_parts), + "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 search request", + ) + + raw_files = data.get("files", []) + if not isinstance(raw_files, list): + break + + for raw_file in raw_files: + if not isinstance(raw_file, dict): + continue + items.append(GoogleDriveClient._file_metadata_from_dict(raw_file)) + + next_page_token_raw = data.get("nextPageToken") + if not isinstance(next_page_token_raw, str) or not next_page_token_raw: + break + next_page_token = next_page_token_raw + + return items + + @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/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 5a9a101..06e0f28 100644 --- a/app/infra/nats.py +++ b/app/infra/nats.py @@ -2,12 +2,18 @@ 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 from app.core.config import settings -from app.core.constant import NOTIFICATION_EVENT_SUBJECT, AUDIT_EVENT_SUBJECT +from app.core.constant import ( + AUDIT_EVENT_SUBJECT, + FINAL_BUCKET_CLEANUP_SUBJECT, + NOTIFICATION_EVENT_SUBJECT, + UPLOAD_GROUP_IMPORT_SUBJECT, +) class Message(BaseModel): @@ -20,9 +26,16 @@ class NatsSubjects(Enum): USER_LOGOUT = "user.logout" NOTIFICATION_EVENT = NOTIFICATION_EVENT_SUBJECT AUDIT_EVENT = AUDIT_EVENT_SUBJECT + STAFF_UPLOAD_GROUP_IMPORT_REQUESTED = UPLOAD_GROUP_IMPORT_SUBJECT + STAFF_UPLOAD_GROUP_CREATED = "staff.upload_group.created" + STAFF_UPLOAD_GROUP_APPROVED = "staff.upload_group.approved" + STAFF_UPLOAD_GROUP_REJECTED = "staff.upload_group.rejected" + 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" + PHOTO_PROCESS = "photo.process" + class NatsClient: _nc: Optional[NATS] = None @@ -44,7 +57,7 @@ async def connect( password=password or settings.NATS_PASSWORD, ) NatsClient._nc = nc - NatsClient._js = nc.jetstream() # type: ignore + NatsClient._js = nc.jetstream() # type: ignore @staticmethod async def close() -> None: @@ -70,11 +83,12 @@ async def subscribe(subject: NatsSubjects | str, callback: Callable[[Any], Any]) await NatsClient.connect() nc = NatsClient._nc assert nc is not None + async def _wrapper(msg: Msg) -> None: await callback(msg.data) subject_name = subject.value if isinstance(subject, NatsSubjects) else subject - await nc.subscribe(subject_name, cb=_wrapper) # type: ignore + await nc.subscribe(subject_name, cb=_wrapper) # type: ignore @staticmethod @@ -83,7 +97,7 @@ async def js_publish(subject: NatsSubjects, message: bytes, stream_name: str) -> await NatsClient.connect() js = NatsClient._js assert js is not None - subject_name = subject.value if isinstance(subject, NatsSubjects) else subject # type: ignore + subject_name = subject.value if isinstance(subject, NatsSubjects) else subject # type: ignore await js.publish(subject_name, message, stream=stream_name) @staticmethod @@ -97,17 +111,35 @@ 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() js = NatsClient._js assert js is not None - subject_name = subject.value await js.subscribe( - subject=subject_name, + subject=subject.value, stream=stream_name, durable=durable_name, cb=_wrapper, 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( # type: ignore + name=stream_name, + config=StreamConfig( + name=stream_name, + subjects=subjects, + ), + ) diff --git a/app/main.py b/app/main.py index 26c4965..7698c6a 100644 --- a/app/main.py +++ b/app/main.py @@ -66,7 +66,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: raise RuntimeError("Cannot connect to MinIO after multiple attempts") from e await asyncio.sleep(RETRY_DELAY) - RedisClient( + RedisClient.init( host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD, diff --git a/app/router/mobile/__init__.py b/app/router/mobile/__init__.py index aa3c807..e43235d 100644 --- a/app/router/mobile/__init__.py +++ b/app/router/mobile/__init__.py @@ -3,6 +3,8 @@ from app.router.mobile.enrollement import router as onboarding_router from app.router.mobile.event import router as event_router from app.router.mobile.notifications import router as mobile_notifications_router +from app.router.mobile.photo_approval import router as photo_approval_router +from app.router.mobile.photos import router as photos_router router = APIRouter(prefix="/user", tags=["user"]) @@ -11,3 +13,5 @@ router.include_router(onboarding_router) router.include_router(event_router) router.include_router(mobile_notifications_router) +router.include_router(photo_approval_router) +router.include_router(photos_router) diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 52e34a1..cc7d1e1 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -4,7 +4,7 @@ from uuid import UUID from app.container import get_container, Container -from app.core.exceptions import AppException +from app.core.constant import AuditEventType from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.schema.request.mobile.auth import ( @@ -23,8 +23,14 @@ async def mobile_register_login( req: MobileAuthRequest, container: Container = Depends(get_container), ) -> MobileAuthResponse: - - return await container.auth_service.mobile_register_login(container.redis, req) + result = await container.auth_service.mobile_register_login(container.redis, req) + event_type = AuditEventType.USER_SIGNUP if result.is_new_user else AuditEventType.USER_LOGIN + await container.audit_service.create_record( + event_type=event_type, + user_id=result.user_id, + metadata={"email": req.email}, + ) + return result @router.post("/refresh", response_model=MobileAuthResponse) @@ -32,21 +38,24 @@ 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( + result = 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), ) + await container.audit_service.create_record( + event_type=AuditEventType.USER_LOGOUT, + user_id=current_user.user_id, + ) + return result @router.post("/revoke-device") @@ -55,7 +64,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 +107,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 +133,6 @@ async def get_me( expires_at=sessions_objs.expires_at, ) - - return MeResponse( user=UserSchema(id=user.id, email=user.email), devices=device_list, 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/mobile/photo_approval.py b/app/router/mobile/photo_approval.py new file mode 100644 index 0000000..321f9f4 --- /dev/null +++ b/app/router/mobile/photo_approval.py @@ -0,0 +1,49 @@ +from typing import Literal +from uuid import UUID + +from fastapi import APIRouter, Depends, Query + +from app.container import Container, get_container +from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.schema.request.mobile.photo_approval import PhotoApprovalRequest + +router = APIRouter(prefix="/photos") + + +@router.get("/approvals") +async def list_my_approvals( + status: Literal["pending", "approved", "rejected"] | None = Query(default=None), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + current_user: MobileUserSchema = Depends(get_current_mobile_user), + container: Container = Depends(get_container), +) -> list[dict[str, object]]: + approvals = [] + async for a in container.photo_approval_querier.list_approvals_by_user_and_status( + user_id=current_user.user_id, + dollar_2=status, + limit=limit, + offset=offset, + ): + approvals.append({ + "id": str(a.id), + "photo_id": str(a.photo_id), + "decision": a.decision, + "decided_at": a.decided_at.isoformat() if a.decided_at else None, + }) + return approvals + + +@router.post("/{photo_id}/decision") +async def decide_photo_approval( + photo_id: UUID, + req: PhotoApprovalRequest, + current_user: MobileUserSchema = Depends(get_current_mobile_user), + container: Container = Depends(get_container), +) -> dict[str, str]: + photo_status = await container.photo_approval_service.decide( + photo_id=photo_id, + user_id=current_user.user_id, + decision=req.decision, + ) + return {"message": "Decision recorded", "photo_status": photo_status} diff --git a/app/router/mobile/photos.py b/app/router/mobile/photos.py new file mode 100644 index 0000000..01bdfe6 --- /dev/null +++ b/app/router/mobile/photos.py @@ -0,0 +1,94 @@ +from typing import Literal +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.token_auth import MobileUserSchema, get_current_mobile_user + +router = APIRouter(prefix="/photos") + + +@router.get("") +async def list_my_photos( + event_id: UUID | None = Query(default=None), + sort: Literal["asc", "desc"] = Query(default="desc"), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + current_user: MobileUserSchema = Depends(get_current_mobile_user), + container: Container = Depends(get_container), +) -> list[dict[str, object]]: + photos = await container.user_photo_service.list_photos( + user_id=current_user.user_id, + event_id=event_id, + sort=sort, + limit=limit, + offset=offset, + ) + return [ + { + "id": str(p.id), + "event_id": str(p.event_id), + "visibility": p.visibility, + "status": str(p.status), + "taken_at": p.taken_at.isoformat() if p.taken_at else None, + "day_number": p.day_number, + "created_at": p.created_at.isoformat(), + } + for p in photos + ] + + +@router.get("/event/{event_id}") +async def list_event_photos( + event_id: UUID, + sort: Literal["asc", "desc"] = Query(default="desc"), + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + current_user: MobileUserSchema = Depends(get_current_mobile_user), + container: Container = Depends(get_container), +) -> dict[str, object]: + photos = await container.user_photo_service.list_event_photos( + user_id=current_user.user_id, + event_id=event_id, + sort=sort, + limit=limit, + offset=offset, + ) + total = await container.user_photo_service.count_event_photos( + user_id=current_user.user_id, + event_id=event_id, + ) + return { + "total": total, + "items": [ + { + "id": str(p.id), + "event_id": str(p.event_id), + "visibility": p.visibility, + "status": str(p.status), + "taken_at": p.taken_at.isoformat() if p.taken_at else None, + "day_number": p.day_number, + "created_at": p.created_at.isoformat(), + } + for p in photos + ], + } + + +@router.get("/{photo_id}/image") +async def get_photo_image( + photo_id: UUID, + current_user: MobileUserSchema = Depends(get_current_mobile_user), + container: Container = Depends(get_container), +) -> Response: + data, filename, content_type = await container.user_photo_service.get_photo_bytes( + user_id=current_user.user_id, + photo_id=photo_id, + ) + return Response( + content=data, + media_type=content_type, + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) diff --git a/app/router/staff/drive.py b/app/router/staff/drive.py index 6a28049..cb9af58 100644 --- a/app/router/staff/drive.py +++ b/app/router/staff/drive.py @@ -1,9 +1,15 @@ -from fastapi import APIRouter, Depends, Query +from typing import Literal + +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 from app.deps.cookie_auth import get_current_staff_user +from app.infra.google_drive import GoogleDriveClient from app.schema.response.staff.drive import ( + DriveBrowseResponse, + DriveItemSchema, GoogleDriveCallbackResponse, GoogleDriveConnectResponse, GoogleDriveConnectionStatusResponse, @@ -17,11 +23,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 +40,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, @@ -68,3 +105,56 @@ async def disconnect_google_drive( ) -> GoogleDriveDisconnectResponse: await container.staff_drive_service.disconnect(current_staff_user.id) return GoogleDriveDisconnectResponse(message="Google Drive disconnected successfully") + + +@router.get("/browse", response_model=DriveBrowseResponse) +async def browse_drive( + folder_id: str | None = Query(default=None), + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> DriveBrowseResponse: + access_token = await container.staff_drive_service.get_access_token_for_staff_user( + current_staff_user.id + ) + files = await GoogleDriveClient.list_folder_contents( + access_token=access_token, folder_id=folder_id, + ) + return DriveBrowseResponse( + items=[ + DriveItemSchema( + id=f.id, + name=f.name, + mime_type=f.mime_type, + size_bytes=f.size_bytes, + is_folder=f.mime_type == GoogleDriveClient._drive_folder_mime_type, + ) + for f in files + ] + ) + + +@router.get("/search", response_model=DriveBrowseResponse) +async def search_drive( + q: str = Query(..., min_length=1), + type: Literal["folder", "image"] | None = Query(default=None), + current_staff_user: StaffUser = Depends(get_current_staff_user), + container: Container = Depends(get_container), +) -> DriveBrowseResponse: + access_token = await container.staff_drive_service.get_access_token_for_staff_user( + current_staff_user.id + ) + files = await GoogleDriveClient.search_files( + access_token=access_token, query=q, file_type=type, + ) + return DriveBrowseResponse( + items=[ + DriveItemSchema( + id=f.id, + name=f.name, + mime_type=f.mime_type, + size_bytes=f.size_bytes, + is_folder=f.mime_type == GoogleDriveClient._drive_folder_mime_type, + ) + for f in files + ] + ) diff --git a/app/router/staff/uploads.py b/app/router/staff/uploads.py index 702cfbc..041181b 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") -@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) @@ -48,13 +59,82 @@ async def list_upload_requests( requests = await container.upload_requests_service.list_requests( current_staff_user=current_staff_user, scope=scope, - status=status + status=status.value if status is not None else None, ) return UploadRequestListResponse.from_models( [(item.request, item.photos) for item in 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/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..f167376 --- /dev/null +++ b/app/router/web/users.py @@ -0,0 +1,106 @@ +from uuid import UUID + +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, 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) +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 to_admin_user_schema(user) + +@router.get("/", response_model=list[AdminUserSchema]) +async def list_users( + limit: int = Query( + settings.ADMIN_USERS_DEFAULT_LIMIT, ge=1, le=settings.ADMIN_USERS_MAX_LIMIT + ), + 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 [to_admin_user_schema(user) 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 to_admin_user_schema(user) + + +@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 to_admin_user_schema(user) + + +@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( + 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) + + +@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 to_admin_user_schema(user) + + +@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 to_admin_user_schema(user) diff --git a/app/schema/notification.py b/app/schema/internal/notification.py similarity index 100% rename from app/schema/notification.py rename to app/schema/internal/notification.py diff --git a/app/schema/internal/single_face_match.py b/app/schema/internal/single_face_match.py new file mode 100644 index 0000000..a5ebe67 --- /dev/null +++ b/app/schema/internal/single_face_match.py @@ -0,0 +1,32 @@ +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"} + + +class ClosestUserMatch(BaseModel): + user_id: UUID + distance: float + + model_config = {"extra": "forbid"} diff --git a/app/schema/dto/staff/uploads.py b/app/schema/internal/uploads.py similarity index 100% rename from app/schema/dto/staff/uploads.py rename to app/schema/internal/uploads.py diff --git a/app/schema/request/mobile/photo_approval.py b/app/schema/request/mobile/photo_approval.py new file mode 100644 index 0000000..0bcb035 --- /dev/null +++ b/app/schema/request/mobile/photo_approval.py @@ -0,0 +1,7 @@ +from typing import Literal + +from pydantic import BaseModel + + +class PhotoApprovalRequest(BaseModel): + decision: Literal["approved", "rejected"] diff --git a/app/schema/request/staff/uploads.py b/app/schema/request/staff/uploads.py index ed50627..5a631b6 100644 --- a/app/schema/request/staff/uploads.py +++ b/app/schema/request/staff/uploads.py @@ -1,10 +1,9 @@ from datetime import datetime -from typing import Literal -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 +from app.schema.internal.uploads import UploadPhotoInput MAX_UPLOAD_BATCH_SIZE = 20 @@ -14,7 +13,7 @@ class CreateUploadRequestPhotoRequest(BaseModel): drive_file_id: str = Field(min_length=1, max_length=255) taken_at: datetime | None = None day_number: int | None = None - visibility: Literal["private","public"] + visibility: str = "private" @field_validator("drive_file_id", mode="before") @classmethod @@ -42,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/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/mobile/auth.py b/app/schema/response/mobile/auth.py index fed1583..154f9a4 100644 --- a/app/schema/response/mobile/auth.py +++ b/app/schema/response/mobile/auth.py @@ -31,3 +31,5 @@ class MobileAuthResponse(BaseModel): refresh_token: str session_id: str expires_in: int + user_id: uuid.UUID + is_new_user: bool = False diff --git a/app/schema/response/staff/drive.py b/app/schema/response/staff/drive.py index 2a2c7ac..b3c63f4 100644 --- a/app/schema/response/staff/drive.py +++ b/app/schema/response/staff/drive.py @@ -23,3 +23,15 @@ class GoogleDriveCallbackResponse(BaseModel): class GoogleDriveDisconnectResponse(BaseModel): message: str + + +class DriveItemSchema(BaseModel): + id: str + name: str + mime_type: str + size_bytes: int + is_folder: bool + + +class DriveBrowseResponse(BaseModel): + items: list[DriveItemSchema] diff --git a/app/schema/response/staff/upload_groups.py b/app/schema/response/staff/upload_groups.py new file mode 100644 index 0000000..55a3f6f --- /dev/null +++ b/app/schema/response/staff/upload_groups.py @@ -0,0 +1,75 @@ +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 + processing_status: str + total_photo_count: int + batch_count: int + processed_photo_count: int + failed_photo_count: int + created_at: datetime + approved_at: datetime | None + rejection_reason: str | None + error_message: 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)), + processing_status=details.group.processing_status, + total_photo_count=details.group.total_photo_count, + batch_count=details.group.batch_count, + processed_photo_count=details.group.processed_photo_count, + failed_photo_count=details.group.failed_photo_count, + created_at=details.group.created_at, + approved_at=details.group.approved_at, + rejection_reason=details.group.rejection_reason, + error_message=details.group.error_message, + 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 74d7b7c..1d29e9a 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/schema/response/web/user.py b/app/schema/response/web/user.py new file mode 100644 index 0000000..bd79627 --- /dev/null +++ b/app/schema/response/web/user.py @@ -0,0 +1,25 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel +from db.generated.models import User + + +class AdminUserSchema(BaseModel): + id: UUID + email: str + display_name: str | None + 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, + ) 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 diff --git a/app/service/face_embedding.py b/app/service/face_embedding.py index 11a0d81..e5333a0 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 @@ -151,6 +170,59 @@ 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( # type: ignore + 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 + + 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/face_match.py b/app/service/face_match.py new file mode 100644 index 0000000..eb1745f --- /dev/null +++ b/app/service/face_match.py @@ -0,0 +1,137 @@ +from __future__ import annotations + +import json +from uuid import UUID + +from sqlalchemy.exc import DBAPIError, SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncConnection + +from app.core.logger import logger +from app.schema.internal.single_face_match import BBoxPayload, ClosestUserMatch, SingleFaceMatchJob +from app.service.user_notification import UserNotificationService +from app.service.users import AuthService +from db.generated import photo_faces as photo_face_queries +from db.generated import photos as photo_queries + + +class SingleFaceMatchService: + def __init__( + self, + *, + conn: AsyncConnection, + photo_face_querier: photo_face_queries.AsyncQuerier, + photo_querier: photo_queries.AsyncQuerier, + user_match_service: AuthService, + user_notification_service: UserNotificationService, + ) -> None: + self.conn = conn + self.photo_face_querier = photo_face_querier + self.photo_querier = photo_querier + self.user_match_service = user_match_service + self.user_notification_service = user_notification_service + + async def process_detected_face( + self, + job: SingleFaceMatchJob, + embedding: list[float], + bbox: BBoxPayload | None, + ) -> None: # noqa: C901 + if not job.image_ref: + logger.warning("Missing image_ref in event payload for photo %s", job.photo_id) + return + + embedding_literal = self._vector_literal(embedding) + bbox_payload = self._serialize_bbox(bbox) + + created_face_match_id: UUID | None = None + matched_user: ClosestUserMatch | None = None + + try: + async with self.conn.begin(): + if not await self.Check_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 + + matched_user = await self.user_match_service.find_closest_user( + embedding_literal=embedding_literal, + ) + if matched_user is None: + logger.info("No user embeddings available for matching, auto-approving photo %s", job.photo_id) + await self.photo_querier.update_photo_status(id=job.photo_id, status="approved") + return + + from app.worker.photo_worker.settings import settings as worker_settings + if matched_user.distance > worker_settings.similarity_threshold: + logger.info( + "Closest user distance %.4f exceeds threshold %.4f for photo %s; auto-approving", + matched_user.distance, worker_settings.similarity_threshold, job.photo_id, + ) + await self.photo_querier.update_photo_status(id=job.photo_id, status="approved") + return + + params = photo_face_queries.PhotoFacesEnsureFaceMatchParams( + photo_id=job.photo_id, + face_index=job.face_index, + column_3=embedding_literal, + bbox=bbox_payload, + user_id=matched_user.user_id, + confidence=matched_user.distance, + ) + result = await self.photo_face_querier.photo_faces_ensure_face_match(params) + if result is None: + logger.warning("Failed to ensure face match for photo %s", job.photo_id) + return + + if result.face_match_id is None: + logger.info("Match already exists for photo %s; skipping", job.photo_id) + else: + created_face_match_id = result.face_match_id + logger.info( + "Inserted face match %s for photo %s", + created_face_match_id, + job.photo_id, + ) + except (DBAPIError, SQLAlchemyError) as exc: + logger.warning("DB write failed for photo %s: %s", job.photo_id, exc) + return + except MemoryError: + logger.error("Out of memory while matching photo %s", job.photo_id) + return + + if created_face_match_id: + await self.photo_querier.update_photo_status( + id=job.photo_id, status="approved", + ) + await self.user_notification_service.create_notification( + user_id=matched_user.user_id, + type="face_match", + payload={ + "photo_id": str(job.photo_id), + }, + ) + + async def Check_photo_exists(self, photo_id: UUID) -> bool: + row = await self.photo_face_querier.photo_faces_photo_exists(id=photo_id) + return row is not None + + async def _match_exists_for_photo(self, photo_id: UUID) -> bool: + row = await self.photo_face_querier.photo_faces_match_exists_for_photo( + photo_id=photo_id, + ) + return row is not None + + @staticmethod + def _vector_literal(embedding: list[float]) -> str: + return "[" + ", ".join(str(x) for x in embedding) + "]" + + @staticmethod + def _serialize_bbox(bbox: BBoxPayload | None) -> str | None: + if bbox is None: + return None + return json.dumps( + {"x1": bbox.x1, "y1": bbox.y1, "x2": bbox.x2, "y2": bbox.y2} + ) diff --git a/app/service/photo_approval.py b/app/service/photo_approval.py new file mode 100644 index 0000000..b6cb870 --- /dev/null +++ b/app/service/photo_approval.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +from uuid import UUID + +from app.core.constant import AuditEventType +from app.core.exceptions import AppException +from app.core.logger import logger +from app.service.audit import AuditService +from app.service.staged_upload_storage import StagedUploadStorageService +from db.generated import photo_approvals as photo_approval_queries +from db.generated import photos as photo_queries + + +class PhotoApprovalService: + def __init__( + self, + *, + photo_approval_querier: photo_approval_queries.AsyncQuerier, + photo_querier: photo_queries.AsyncQuerier, + storage_service: StagedUploadStorageService, + audit_service: AuditService | None = None, + ) -> None: + self._approval_querier = photo_approval_querier + self._photo_querier = photo_querier + self._storage_service = storage_service + self._audit_service = audit_service + + async def decide( + self, + *, + photo_id: UUID, + user_id: UUID, + decision: str, + ) -> str: + updated = await self._approval_querier.update_photo_approval_decision( + photo_id=photo_id, + decision=decision, + user_id=user_id, + ) + if updated is None: + raise AppException.not_found("Photo approval not found") + + if self._audit_service is not None: + await self._audit_service.create_record( + event_type=AuditEventType.PHOTO_APPROVAL_DECIDED, + user_id=user_id, + metadata={"photo_id": str(photo_id), "decision": decision}, + ) + + approvals = [] + async for a in self._approval_querier.get_photo_approvals_by_photo_id(photo_id=photo_id): + approvals.append(a) + + pending = [a for a in approvals if a.decision == "pending"] + if pending: + return "pending" + + rejected = [a for a in approvals if a.decision == "rejected"] + if rejected: + await self._photo_querier.update_photo_status(id=photo_id, status="rejected") + await self._delete_photo_storage(photo_id) + return "rejected" + + await self._photo_querier.update_photo_status(id=photo_id, status="approved") + return "approved" + + async def _delete_photo_storage(self, photo_id: UUID) -> None: + photo = await self._photo_querier.get_photo_by_id(id=photo_id) + if photo is None: + return + try: + await self._storage_service.delete_storage_key(photo.storage_key) + logger.info("Deleted storage for rejected photo %s", photo_id) + except Exception as exc: + logger.warning("Failed to delete storage for photo %s: %s", photo_id, exc) 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/staff_drive.py b/app/service/staff_drive.py index 4e4019f..1060fa0 100644 --- a/app/service/staff_drive.py +++ b/app/service/staff_drive.py @@ -2,7 +2,9 @@ import hashlib import json import secrets +import urllib.parse import uuid +from datetime import datetime, timedelta, timezone from cryptography.fernet import Fernet, InvalidToken @@ -19,6 +21,7 @@ class StaffDriveService: STATE_PREFIX = "google_drive_oauth_state:{state}" STATE_TTL_SECONDS = 600 PROVIDER = "google_drive" + TOKEN_REFRESH_BUFFER = timedelta(minutes=5) def __init__( self, @@ -30,17 +33,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 +77,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 +100,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( @@ -99,8 +130,59 @@ async def get_active_connection_or_raise( raise AppException.bad_request("Staff Google Drive is not connected") return connection + @classmethod + def _token_needs_refresh(cls, connection: StaffDriveConnection) -> bool: + if connection.token_expires_at is None: + return False + return connection.token_expires_at <= datetime.now(timezone.utc) + cls.TOKEN_REFRESH_BUFFER + + async def _refresh_connection_access_token( + self, + connection: StaffDriveConnection, + ) -> StaffDriveConnection: + if connection.refresh_token is None: + raise AppException.bad_request( + "Google Drive connection expired and must be reconnected" + ) + + refresh_token = self.decrypt(connection.refresh_token) + token = await GoogleDriveClient.refresh_access_token(refresh_token) + + encrypted_access_token = self._encrypt(token.access_token) + encrypted_refresh_token = connection.refresh_token + if token.refresh_token: + encrypted_refresh_token = self._encrypt(token.refresh_token) + + refreshed_connection = await self.drive_connection_querier.upsert_staff_drive_connection( + arg=drive_queries.UpsertStaffDriveConnectionParams( + staff_user_id=connection.staff_user_id, + provider=connection.provider, + google_email=connection.google_email, + google_account_id=connection.google_account_id, + access_token=encrypted_access_token, + refresh_token=encrypted_refresh_token, + token_expires_at=token.expires_at, + scopes=token.scope, + ) + ) + if refreshed_connection is None: + raise AppException.internal_error("Failed to refresh Google Drive connection") + + return refreshed_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) + if self._token_needs_refresh(connection): + connection = await self._refresh_connection_access_token(connection) + return self.decrypt(connection.access_token) + + async def get_system_access_token(self) -> str: + """Get an access token from any active staff Drive connection.""" + connection = await self.drive_connection_querier.get_any_active_staff_drive_connection() + if connection is None: + raise AppException.not_found("No active Google Drive connection") + if self._token_needs_refresh(connection): + connection = await self._refresh_connection_access_token(connection) return self.decrypt(connection.access_token) async def disconnect(self, staff_user_id: uuid.UUID) -> None: @@ -125,3 +207,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))) 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") diff --git a/app/service/upload_requests.py b/app/service/upload_requests.py index 13759dd..f794378 100644 --- a/app/service/upload_requests.py +++ b/app/service/upload_requests.py @@ -1,30 +1,40 @@ +from collections import defaultdict from collections.abc import Sequence from dataclasses import dataclass -from collections import defaultdict +from datetime import datetime, timezone import json from typing import Literal import uuid +from sqlalchemy.exc import IntegrityError + +from app.core.constant import AuditEventType 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.schema.internal.uploads import UploadPhotoInput +from app.service.audit import AuditService 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 ( + Photo, StaffRole, StaffUser, UploadRequest, + UploadRequestGroup, UploadRequestPhoto, - UploadRequestStatus, ) +from app.worker.upload_group_worker.schema.event import UploadGroupImportRequestedEvent @dataclass @@ -33,25 +43,37 @@ 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 + _import_finished_statuses = {"completed", "failed"} 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, staged_upload_storage: StagedUploadStorageService, staff_drive_service: StaffDriveService, staff_notifications_service: StaffNotificationsService, + audit_service: AuditService | None = None, ): + 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 self.staged_upload_storage = staged_upload_storage self.staff_drive_service = staff_drive_service self.staff_notifications_service = staff_notifications_service + self.audit_service = audit_service @staticmethod def _status_value(status: object) -> str: @@ -61,6 +83,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) @@ -80,11 +112,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] @@ -101,6 +136,35 @@ 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], + delete_group: bool = True, + ) -> 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, + ) + + if not delete_group: + return + + 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: @@ -133,7 +197,7 @@ async def _list_request_photos_by_request_ids( 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_i_ds( + async for photo in self.upload_request_photo_querier.list_upload_request_photos_by_upload_request_ids( dollar_1=list(request_ids) ): photos_by_request_id[photo.upload_request_id].append(photo) @@ -163,17 +227,17 @@ async def _create_staged_photo( try: created_photo = await self.upload_request_photo_querier.create_upload_request_photo( - arg=upload_request_photo_queries.CreateUploadRequestPhotoParams( - 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", + upload_request_photo_queries.CreateUploadRequestPhotoParams( + 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: @@ -198,6 +262,154 @@ 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) + upload_request = None + try: + upload_request = await self.upload_request_querier.create_upload_request( + upload_request_queries.CreateUploadRequestParams( + 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], list[Photo]]: + 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] = [] + created_photos: list[Photo] = [] + 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( + 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") + created_photos.append(created_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, created_photos + + 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, *, @@ -210,6 +422,45 @@ 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_group_import_completed( + self, + group: UploadRequestGroup, + ) -> None: + if group.processing_status != "completed": + raise AppException.bad_request("Upload request group import is not completed") + + 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, *, @@ -221,6 +472,295 @@ async def _publish_event( except Exception as exc: logger.warning("Failed to publish upload request event %s: %s", subject.value, exc) + async def _audit(self, event_type: AuditEventType, **metadata: object) -> None: + if self.audit_service is not None: + await self.audit_service.create_record( + event_type=event_type, + metadata={k: str(v) for k, v in metadata.items()}, + ) + + async def _publish_photo_process_events(self, photos: list[Photo]) -> None: + for photo in photos: + await self._publish_event( + subject=NatsSubjects.PHOTO_PROCESS, + payload={ + "photo_id": str(photo.id), + "image_ref": photo.storage_key, + "event_id": str(photo.event_id), + }, + ) + if photos: + logger.info("Published %d photo process events", len(photos)) + + async def _mark_group_import_failed( + self, + *, + group_id: uuid.UUID, + total_photo_count: int, + batch_count: int, + processed_photo_count: int, + failed_photo_count: int, + error_message: str, + ) -> UploadRequestGroup | None: + return await self.upload_request_group_querier.fail_upload_request_group_processing( + upload_request_group_queries.FailUploadRequestGroupProcessingParams( + id=group_id, + total_photo_count=total_photo_count, + batch_count=batch_count, + processed_photo_count=processed_photo_count, + failed_photo_count=failed_photo_count, + error_message=error_message, + ) + ) + + async def create_upload( + self, + *, + 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, + ) + + async def create_request( + self, + *, + event_id: uuid.UUID, + photos: Sequence[UploadPhotoInput], + requested_by: StaffUser, + ) -> UploadRequestDetails: + 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: + await self.staff_drive_service.get_access_token_for_staff_user(requested_by.id) + try: + upload_group = await self.upload_request_group_querier.create_upload_request_group( + upload_request_group_queries.CreateUploadRequestGroupParams( + event_id=event_id, + folder_id=folder_id, + requested_by=requested_by.id, + total_photo_count=0, + batch_count=0, + ) + ) + except IntegrityError as exc: + self._raise_integrity_error(exc) + if upload_group is None: + raise AppException.internal_error("Failed to create upload request group") + + await self._publish_event( + subject=NatsSubjects.STAFF_UPLOAD_GROUP_IMPORT_REQUESTED, + payload=UploadGroupImportRequestedEvent( + group_id=upload_group.id, + event_id=event_id, + folder_id=folder_id, + requested_by=requested_by.id, + visibility=visibility, + day_number=day_number, + submitted_at=datetime.now(timezone.utc), + ).model_dump(mode="json"), + ) + return UploadRequestGroupDetails(group=upload_group, requests=[]) + + async def process_group_import( + self, + *, + group_id: uuid.UUID, + visibility: str, + day_number: int | None, + ) -> UploadRequestGroupDetails | None: + upload_group = await self.upload_request_group_querier.start_upload_request_group_processing( + id=group_id + ) + if upload_group is None: + existing_group = await self.upload_request_group_querier.get_upload_request_group_by_id( + id=group_id + ) + if existing_group is None: + logger.warning("Upload request group %s not found for import", group_id) + return None + if existing_group.processing_status in self._import_finished_statuses: + return UploadRequestGroupDetails(group=existing_group, requests=[]) + logger.info( + "Upload request group %s is already being processed with status %s", + group_id, + existing_group.processing_status, + ) + return None + + requested_by = await self.staff_drive_service.staff_user_querier.get_staff_user_by_id( + id=upload_group.requested_by + ) + if requested_by is None: + await self._mark_group_import_failed( + group_id=group_id, + total_photo_count=0, + batch_count=0, + processed_photo_count=0, + failed_photo_count=0, + error_message="Staff user not found for upload group import", + ) + return None + + created_requests: list[UploadRequestDetails] = [] + photo_inputs: list[UploadPhotoInput] = [] + try: + 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=upload_group.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: + await self._mark_group_import_failed( + group_id=group_id, + total_photo_count=0, + batch_count=0, + processed_photo_count=0, + failed_photo_count=0, + error_message="Selected Google Drive folder does not contain valid images", + ) + return await self.get_group_details( + group_id=group_id, + current_staff_user=requested_by, + ) + + photo_batches = self._chunk_photo_inputs(photo_inputs, self._max_request_batch_size) + await self.upload_request_group_querier.update_upload_request_group_import_progress( + upload_request_group_queries.UpdateUploadRequestGroupImportProgressParams( + id=group_id, + total_photo_count=len(photo_inputs), + batch_count=len(photo_batches), + processed_photo_count=0, + failed_photo_count=0, + ) + ) + + processed_photo_count = 0 + for batch in photo_batches: + request_details = await self._create_request_with_access_token( + event_id=upload_group.event_id, + photos=batch, + requested_by=requested_by, + access_token=access_token, + group_id=upload_group.id, + publish_event=False, + ) + created_requests.append(request_details) + processed_photo_count += len(batch) + await self.upload_request_group_querier.update_upload_request_group_import_progress( + upload_request_group_queries.UpdateUploadRequestGroupImportProgressParams( + id=group_id, + total_photo_count=len(photo_inputs), + batch_count=len(photo_batches), + processed_photo_count=processed_photo_count, + failed_photo_count=0, + ) + ) + + completed_group = await self.upload_request_group_querier.complete_upload_request_group_processing( + upload_request_group_queries.CompleteUploadRequestGroupProcessingParams( + id=group_id, + total_photo_count=len(photo_inputs), + batch_count=len(photo_batches), + processed_photo_count=processed_photo_count, + failed_photo_count=0, + ) + ) + if completed_group is None: + raise AppException.internal_error("Failed to complete upload group import") + + 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_GROUP_CREATED, + payload={ + "group_id": str(completed_group.id), + "event_id": str(completed_group.event_id), + "requested_by": str(requested_by.id), + "total_photo_count": completed_group.total_photo_count, + "batch_count": completed_group.batch_count, + }, + ) + return UploadRequestGroupDetails(group=completed_group, requests=created_requests) + except Exception as exc: + 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=group_id, + created_requests=created_requests, + delete_group=False, + ) + await self._mark_group_import_failed( + group_id=group_id, + total_photo_count=len(photo_inputs), + batch_count=len(self._chunk_photo_inputs(photo_inputs, self._max_request_batch_size)) + if photo_inputs + else 0, + processed_photo_count=0, + failed_photo_count=len(photo_inputs), + error_message=str(exc), + ) + logger.exception("Failed to import upload request group %s", group_id) + return None + async def get_request_details( self, *, @@ -259,97 +799,45 @@ async def get_request_photo_preview( 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 - ) - upload_request: UploadRequest | None = None - - - try: - upload_request = await self.upload_request_querier.create_upload_request( - event_id=event_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 - - 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) - async def list_requests( self, *, current_staff_user: StaffUser, scope: Literal["my", "all"], - status: UploadRequestStatus | None, + status: str | None, ) -> list[UploadRequestDetails]: 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 - if requested_by is None: - logger.info("hello") - raise AppException.not_found("not requests") - else : - request_rows: list[UploadRequest] = [] - async for upload_request in self.upload_request_querier.list_upload_requests( - dollar_1=requested_by, - p2=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=photos_by_request_id.get(upload_request.id, []), - ) - ) - return requests + request_rows: list[UploadRequest] = [] + if requested_by is not None and status is not None: + iterator = self.upload_request_querier.list_upload_requests_by_requester_and_status( + requested_by=requested_by, + status=status, + ) + elif requested_by is not None: + iterator = self.upload_request_querier.list_upload_requests_by_requester( + requested_by=requested_by + ) + elif status is not None: + iterator = self.upload_request_querier.list_upload_requests_by_status(status=status) + else: + iterator = self.upload_request_querier.list_upload_requests() + + async for upload_request in iterator: + 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] + ) + return [ + UploadRequestDetails( + request=upload_request, + photos=photos_by_request_id.get(upload_request.id, []), + ) + for upload_request in request_rows + ] async def list_request_photos( self, @@ -362,58 +850,110 @@ 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, + 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, []), ) - finalized_storage_keys.append(final_storage_key) - created_photo = await self.photo_querier.create_photo( - arg=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, - ) + 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] = [] + if requested_by is not None and status is not None: + iterator = self.upload_request_group_querier.list_upload_request_groups_by_requester_and_status( + requested_by=requested_by, + status=status, + ) + elif requested_by is not None: + iterator = self.upload_request_group_querier.list_upload_request_groups_by_requester( + requested_by=requested_by + ) + elif status is not None: + iterator = self.upload_request_group_querier.list_upload_request_groups_by_status( + status=status + ) + else: + iterator = self.upload_request_group_querier.list_upload_request_groups() + + async for group in iterator: + 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 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") + ) + 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, created_photos = ( + 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", @@ -425,20 +965,26 @@ 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, + }, + ) + await self._publish_photo_process_events(created_photos) + await self._audit( + AuditEventType.UPLOAD_REQUEST_APPROVED, + request_id=upload_request.id, + approved_by=approved_by.id, + ) 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), @@ -451,28 +997,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", @@ -496,4 +1027,175 @@ async def reject_request( }, ) await self._delete_staging_objects_best_effort(staged_photos) + await self._audit( + AuditEventType.UPLOAD_REQUEST_REJECTED, + request_id=upload_request.id, + approved_by=approved_by.id, + reason=reason or "", + ) 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) + self._ensure_group_import_completed(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] = [] + all_created_photos: list[Photo] = [] + finalized_storage_keys: list[str] = [] + try: + for request_details in pending_requests: + approved_request, staged_photos, request_storage_keys, created_photos = ( + 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) + all_created_photos.extend(created_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, + }, + ) + await self._publish_photo_process_events(all_created_photos) + await self._audit( + AuditEventType.UPLOAD_REQUEST_APPROVED, + group_id=upload_group.id, + approved_by=approved_by.id, + ) + 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) + self._ensure_group_import_completed(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/app/service/user_notification.py b/app/service/user_notification.py index 1d73971..a269ff1 100644 --- a/app/service/user_notification.py +++ b/app/service/user_notification.py @@ -2,8 +2,10 @@ import uuid from app.core.exceptions import AppException -from app.schema.notification import UnifiedNotification +from app.core.logger import logger +from app.schema.internal.notification import UnifiedNotification from app.worker.notification.notification_queue import NotificationQueue +from db.generated import devices as device_queries from db.generated import notifications as notification_queries from db.generated.models import Notification @@ -13,9 +15,20 @@ def __init__( self, notification_querier: notification_queries.AsyncQuerier, notification_queue: NotificationQueue, + device_querier: device_queries.AsyncQuerier | None = None, ) -> None: self.notification_querier = notification_querier self._notification_queue = notification_queue + self._device_querier = device_querier + + async def _resolve_tokens(self, user_id: uuid.UUID) -> list[str]: + if self._device_querier is None: + return [] + tokens: list[str] = [] + async for device in self._device_querier.list_user_devices(user_id=user_id): + if device.is_active and not device.is_invalid_token and device.push_token: + tokens.append(device.push_token) + return tokens async def create_notification( self, @@ -34,6 +47,13 @@ async def create_notification( raise AppException.internal_error("Failed to create user notification") if notification is not None: + if not notification.tokens: + tokens = await self._resolve_tokens(user_id) + if tokens: + notification = notification.model_copy(update={"tokens": tokens}) + else: + logger.info("No active push tokens for user %s, skipping push", user_id) + return notification_record await self._notification_queue.enqueue_notification(notification) return notification_record diff --git a/app/service/user_photo.py b/app/service/user_photo.py new file mode 100644 index 0000000..f10a630 --- /dev/null +++ b/app/service/user_photo.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from uuid import UUID + +from app.core.exceptions import AppException +from app.core.logger import logger +from app.infra.google_drive import GoogleDriveClient +from app.infra.minio import Bucket, IMAGES_BUCKET_NAME +from app.service.staff_drive import StaffDriveService +from db.generated import photo_approvals as photo_approval_queries +from db.generated import photo_faces as photo_face_queries +from db.generated import photos as photo_queries +from db.generated.models import Photo +from db.generated.photos import ListEventPhotosForUserParams, ListUserPhotosParams + + +class UserPhotoService: + def __init__( + self, + *, + photo_querier: photo_queries.AsyncQuerier, + photo_face_querier: photo_face_queries.AsyncQuerier, + photo_approval_querier: photo_approval_queries.AsyncQuerier, + staff_drive_service: StaffDriveService, + ) -> None: + self._photo_querier = photo_querier + self._photo_face_querier = photo_face_querier + self._photo_approval_querier = photo_approval_querier + self._staff_drive_service = staff_drive_service + + async def list_photos( + self, + *, + user_id: UUID, + event_id: UUID | None = None, + sort: str = "desc", + limit: int = 50, + offset: int = 0, + ) -> list[Photo]: + photos: list[Photo] = [] + async for photo in self._photo_querier.list_user_photos( + ListUserPhotosParams( + user_id=user_id, + column_2=event_id, # type: ignore[arg-type] + column_3=sort, + limit=limit, + offset=offset, + ) + ): + photos.append(photo) + return photos + + async def list_event_photos( + self, + *, + user_id: UUID, + event_id: UUID, + sort: str = "desc", + limit: int = 50, + offset: int = 0, + ) -> list[Photo]: + photos: list[Photo] = [] + async for photo in self._photo_querier.list_event_photos_for_user( + ListEventPhotosForUserParams( + user_id=user_id, + event_id=event_id, + column_3=sort, + limit=limit, + offset=offset, + ) + ): + photos.append(photo) + return photos + + async def count_event_photos( + self, + *, + user_id: UUID, + event_id: UUID, + ) -> int: + count = await self._photo_querier.count_event_photos_for_user( + user_id=user_id, event_id=event_id, + ) + return count or 0 + + async def get_photo_bytes( + self, + *, + user_id: UUID, + photo_id: UUID, + ) -> tuple[bytes, str, str]: + """Returns (image_bytes, filename, content_type). + Tries MinIO first, falls back to Google Drive if cleaned up.""" + + photo = await self._photo_querier.get_photo_by_id(id=photo_id) + if photo is None: + raise AppException.not_found("Photo not found") + + if photo.visibility != "public": + has_access = await self._user_has_access(user_id, photo_id) + if not has_access: + raise AppException.forbidden("You don't have access to this photo") + + # Try bucket first + try: + bucket = Bucket(IMAGES_BUCKET_NAME, "") + data, filename, content_type = await bucket.get(photo.storage_key) + return data, filename, content_type + except Exception: + logger.info("Photo %s not in bucket, trying Drive fallback", photo_id) + + # Fallback: get drive_file_id from upload_request_photos + drive_file_id = await self._photo_querier.get_drive_file_id_for_photo( + final_storage_key=photo.storage_key, + ) + if drive_file_id is None: + raise AppException.not_found("Photo no longer available") + + # Get an access token from any active staff Drive connection + access_token = await self._get_any_drive_token() + + download = await GoogleDriveClient.download_file( + access_token=access_token, + file_id=drive_file_id, + ) + return download.content, download.metadata.name, download.metadata.mime_type + + async def _user_has_access(self, user_id: UUID, photo_id: UUID) -> bool: + """Check if user has a face_match or photo_approval for this photo.""" + match = await self._photo_face_querier.user_has_face_match_for_photo( + photo_id=photo_id, user_id=user_id, + ) + if match is not None: + return True + async for approval in self._photo_approval_querier.get_photo_approvals_by_photo_id(photo_id=photo_id): + if approval.user_id == user_id: + return True + return False + + async def _get_any_drive_token(self) -> str: + """Get a valid Drive access token from the staff drive service. + Uses the system/admin drive connection.""" + try: + return await self._staff_drive_service.get_system_access_token() + except Exception: + raise AppException.internal_error( + "No active Drive connection available to serve this photo" + ) diff --git a/app/service/users.py b/app/service/users.py index 0e54045..649df54 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 +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 @@ -23,6 +22,7 @@ from db.generated.models import User, UserDevice from app.core.logger import logger from app.service.face_embedding import FaceImagePayload, FaceEmbeddingService +from app.schema.internal.single_face_match import ClosestUserMatch class AuthService: @@ -84,12 +84,16 @@ async def mobile_register_login( existing_user = await self.user_querier.get_user_by_email(email=req.email) user: User | None = None + is_new_user = False 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 logger.info("existing user login: %s", req.email) else: + is_new_user = True hashed = hash_password(req.password) logger.info("creating new user for %s", req.email) user = await self.user_querier.create_user( @@ -103,8 +107,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: @@ -143,8 +145,10 @@ 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, + user_id=user_id, + is_new_user=is_new_user, ) async def refresh_token( @@ -166,15 +170,11 @@ 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") - - await redis.expire(session_key, AuthService.REDIS_SESSION_TTL) + user = await self.user_querier.get_user_by_id(id=session.user_id) + if not user: + raise AppException.unauthorized("User not found") + if user.blocked: + raise AppException.forbidden("User is blocked") new_access_token = create_acces_mobile_token(session_id) new_refresh_token = create_refresh_mobile_token(session_id) @@ -185,6 +185,7 @@ async def refresh_token( refresh_token=new_refresh_token, session_id=session_id, expires_in=expiry, + user_id=session.user_id, ) async def logout( @@ -201,16 +202,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, @@ -232,13 +230,137 @@ 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) + + async def create_user( + self, + *, + email: str, + password: str, + display_name: str | None = None, + blocked: bool = False, + ) -> User: + try: + 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 + 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) + if not user: + raise AppException.not_found("User not found") + return user + + async def list_users(self, *, limit: int, offset: int) -> list[User]: + 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, + *, + user_id: uuid.UUID, + email: str | None = None, + display_name: str | None = None, + blocked: bool | None = None, + ) -> User: + 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 + except Exception as exc: + logger.error("Failed to update user: %s", exc) + raise DBException.handle(exc) + + 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 Exception as exc: + logger.error("Failed to delete user: %s", exc) + raise DBException.handle(exc) + + async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: + try: + user = await self.user_querier.set_user_blocked(blocked=True, id=user_id) + if not user: + raise AppException.not_found("User not found") + + session_key = constant.RedisKey.UserSessionByUser.value.format( + user_id=user_id + ) + await redis.delete(session_key) + + return user + except Exception as exc: + logger.error("Failed to block user: %s", exc) + raise DBException.handle(exc) + + async def unblock_user(self, *, user_id: uuid.UUID) -> User: + try: + user = await self.user_querier.set_user_blocked(blocked=False, id=user_id) + if not user: + raise AppException.not_found("User not found") + return user + except Exception as exc: + logger.error("Failed to unblock user: %s", exc) + raise DBException.handle(exc) + + async def find_closest_user(self, *, embedding_literal: str) -> ClosestUserMatch | None: + row = await self.user_querier.find_closest_user_by_embedding( + dollar_1=embedding_literal, + ) + if row is None or row.distance is None: + return None + return ClosestUserMatch(user_id=row.id, distance=float(row.distance)) diff --git a/app/worker/audit/__init__.py b/app/worker/audit/__init__.py index dcd57fc..29d7469 100644 --- a/app/worker/audit/__init__.py +++ b/app/worker/audit/__init__.py @@ -1,6 +1,12 @@ """Audit worker package exports.""" from __future__ import annotations -from .main import main # noqa: F401 - __all__ = ["main"] + + +def __getattr__(name: str): # type: ignore[no-untyped-def] + if name == "main": + from .main import main + + return main + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/app/worker/notification/firebase.py b/app/worker/notification/firebase.py index ffc2360..5615995 100644 --- a/app/worker/notification/firebase.py +++ b/app/worker/notification/firebase.py @@ -2,13 +2,13 @@ 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 -from app.schema.notification import UnifiedNotification +from app.schema.internal.notification import UnifiedNotification INVALID_TOKEN_CODES = { diff --git a/app/worker/notification/notification_queue.py b/app/worker/notification/notification_queue.py index bf1589d..463de93 100644 --- a/app/worker/notification/notification_queue.py +++ b/app/worker/notification/notification_queue.py @@ -1,7 +1,7 @@ from typing import Sequence from pydantic import BaseModel, ConfigDict, Field from app.infra.nats import NatsClient -from app.schema.notification import NotificationPriority, PRIORITY_ORDER, UnifiedNotification +from app.schema.internal.notification import NotificationPriority, PRIORITY_ORDER, UnifiedNotification from app.worker.notification.settings import NotificationWorkerSettings diff --git a/app/worker/notification/settings.py b/app/worker/notification/settings.py index 4d23dd7..7bb2aa8 100644 --- a/app/worker/notification/settings.py +++ b/app/worker/notification/settings.py @@ -1,11 +1,11 @@ from __future__ import annotations -from typing import Sequence +from typing import ClassVar, Sequence from pydantic import Field -from pydantic_settings import BaseSettings +from pydantic_settings import BaseSettings, SettingsConfigDict -from app.schema.notification import NotificationPriority, PRIORITY_ORDER +from app.schema.internal.notification import NotificationPriority, PRIORITY_ORDER class NotificationWorkerSettings(BaseSettings): @@ -19,15 +19,14 @@ class NotificationWorkerSettings(BaseSettings): nats_user: str = Field("") nats_password: str = Field("") firebase_credentials_path: str | None = Field(None) - MAX_SEND_ATTEMPTS = 5 - BASE_RETRY_DELAY = 2 - TTL_SECONDS = 30 * 24 * 3600 - CONCURRENCY = 10 - RATE_LIMIT = 50 - RATE_PERIOD = 1.0 + MAX_SEND_ATTEMPTS: ClassVar[int] = 5 + BASE_RETRY_DELAY: ClassVar[int] = 2 + TTL_SECONDS: ClassVar[int] = 30 * 24 * 3600 + CONCURRENCY: ClassVar[int] = 10 + RATE_LIMIT: ClassVar[int] = 50 + RATE_PERIOD: ClassVar[float] = 1.0 - class Config: - env_prefix = "NOTIFICATIONS_" + model_config = SettingsConfigDict(env_prefix="NOTIFICATIONS_") def subject_for(self, priority: NotificationPriority) -> str: return f"{self.subject_prefix}.{priority.value}" @@ -35,4 +34,4 @@ def subject_for(self, priority: NotificationPriority) -> str: def priority_subjects(self) -> Sequence[str]: return [self.subject_for(priority) for priority in PRIORITY_ORDER] -NotifSetting = NotificationWorkerSettings() # type: ignore +NotifSetting = NotificationWorkerSettings() # type: ignore diff --git a/app/worker/photo_worker/__init__.py b/app/worker/photo_worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/worker/photo_worker/main.py b/app/worker/photo_worker/main.py new file mode 100644 index 0000000..85e70c2 --- /dev/null +++ b/app/worker/photo_worker/main.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import asyncio +import json +from enum import Enum + +from sqlalchemy.exc import DBAPIError, SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncConnection + +from app.container import Container +from app.core.config import settings +from app.core.constant import MINIO_URL_PREFIX +from app.core.logger import logger +from app.infra.database import engine +from app.infra.minio import Bucket, IMAGES_BUCKET_NAME, init_minio_client +from app.infra.nats import NatsClient, NatsSubjects +from app.infra.redis import RedisClient +from app.schema.internal.notification import NotificationPriority, UnifiedNotification +from app.schema.internal.single_face_match import BBoxPayload +from app.service.face_embedding import DetectedFace, FaceEmbeddingService, FaceImagePayload +from app.service.face_match import SingleFaceMatchService +from app.service.user_notification import UserNotificationService +from app.worker.photo_worker.schema.event import PhotoProcessEvent +from app.worker.photo_worker.settings import settings as worker_settings +from db.generated import photo_faces as photo_face_queries +from db.generated import photos as photo_queries +from db.generated import processing_jobs as processing_job_queries +from db.generated.photo_faces import InsertPhotoFaceWithApprovalParams + + +class PhotoApprovalDecision(str, Enum): + PENDING = "pending" + APPROVED = "approved" + REJECTED = "rejected" + + +class PhotoWorker: + + def __init__( + self, + conn: AsyncConnection, + face_embedding_service: FaceEmbeddingService, + single_face_service: SingleFaceMatchService, + user_notification_service: UserNotificationService, + photo_face_querier: photo_face_queries.AsyncQuerier, + photo_querier: photo_queries.AsyncQuerier, + processing_job_querier: processing_job_queries.AsyncQuerier | None = None, + ) -> None: + self._conn = conn + self._face_service = face_embedding_service + self._single_face_service = single_face_service + self._notification_service = user_notification_service + self._photo_face_querier = photo_face_querier + self._photo_querier = photo_querier + self._pj_querier = processing_job_querier + + async def handle_message(self, data: bytes) -> None: + event = self._parse_event(data) + if event is None: + return + + job = await self._create_job(event) + + try: + payload = await self._load_image(event.image_ref) + except Exception as exc: + logger.warning("Failed to load image for photo %s: %s", event.photo_id, exc) + await self._update_job(job, "failed") + return + + await self._update_job(job, "running") + + try: + faces = await self._face_service.detect_faces(payload) + except Exception as exc: + logger.warning("Face detection failed for photo %s: %s", event.photo_id, exc) + await self._update_job(job, "failed") + return + + if not faces: + logger.info("No faces detected in photo %s, marking as public", event.photo_id) + await self._photo_querier.update_photo_status(id=event.photo_id, status="approved") + await self._photo_querier.update_photo_visibility(id=event.photo_id, visibility="public") + await self._update_job(job, "completed") + await self._schedule_cleanup(event.image_ref) + return + + if len(faces) == 1: + await self._handle_single_face(event, faces[0]) + else: + await self._handle_group_photo(event, faces) + + await self._update_job(job, "completed") + await self._publish_audit(event, len(faces)) + await self._schedule_cleanup(event.image_ref) + + + + async def _handle_single_face(self, event: PhotoProcessEvent, face: DetectedFace) -> None: + from app.schema.internal.single_face_match import SingleFaceMatchJob + + bbox = BBoxPayload( + x1=float(face.bbox[0]), + y1=float(face.bbox[1]), + x2=float(face.bbox[2]), + y2=float(face.bbox[3]), + ) + + job = SingleFaceMatchJob( + photo_id=event.photo_id, + face_index=0, + image_ref=event.image_ref, + bbox=bbox, + faces_detected=1, + ) + + try: + await self._single_face_service.process_detected_face(job, face.embedding, bbox) + except Exception as exc: + logger.exception("Single face match failed for photo %s: %s", event.photo_id, exc) + + + + async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[DetectedFace]) -> None: + logger.info("Processing group photo %s with %d faces", event.photo_id, len(faces)) + + for face_index, face in enumerate(faces): + bbox_json = json.dumps({ + "x1": float(face.bbox[0]), + "y1": float(face.bbox[1]), + "x2": float(face.bbox[2]), + "y2": float(face.bbox[3]), + }) + + embedding_literal = "[" + ", ".join(str(x) for x in face.embedding) + "]" + + try: + approval = await self._photo_face_querier.insert_photo_face_with_approval( + InsertPhotoFaceWithApprovalParams( + photo_id=event.photo_id, + face_index=face_index, + column_3=embedding_literal, + face_embedding=worker_settings.similarity_threshold, + bbox=bbox_json, + decision=PhotoApprovalDecision.PENDING.value, + ) + ) + except (DBAPIError, SQLAlchemyError) as exc: + logger.warning( + "DB error inserting face %d for photo %s: %s", + face_index, event.photo_id, exc, + ) + continue + + if approval is None: + logger.info("No match for face %d in photo %s", face_index, event.photo_id) + continue + + try: + await self._notification_service.create_notification( + user_id=approval.user_id, + type="photo_approval", + payload={"photo_id": str(approval.photo_id)}, + notification=UnifiedNotification( + title="You were found in a photo", + body="Tap to review and approve or reject", + data={ + "photo_id": str(approval.photo_id), + "type": "photo_approval", + }, + tokens=[], + priority=NotificationPriority.NORMAL, + ), + ) + logger.info("Notified user %s for group photo %s", approval.user_id, approval.photo_id) + except Exception as exc: + logger.warning( + "Failed to notify user %s for photo %s: %s", + approval.user_id, event.photo_id, exc, + ) + + + async def _create_job(self, event: PhotoProcessEvent) -> object | None: + if self._pj_querier is None: + return None + try: + return await self._pj_querier.create_processing_job( + photo_id=event.photo_id, job_type="face_detection", + ) + except Exception as exc: + logger.warning("Failed to create processing job for photo %s: %s", event.photo_id, exc) + return None + + async def _update_job(self, job: object | None, status: str) -> None: + if job is None or self._pj_querier is None: + return + try: + await self._pj_querier.update_processing_job_status(id=job.id, status=status) # type: ignore[union-attr] + except Exception as exc: + logger.warning("Failed to update processing job: %s", exc) + + @staticmethod + async def _publish_audit(event: PhotoProcessEvent, faces_count: int) -> None: + from app.core.constant import AuditEventType + from app.worker.audit.schema.audit import AuditEventMessage + msg = AuditEventMessage( + event_type=AuditEventType.PHOTO_PROCESSED, + metadata={"photo_id": str(event.photo_id), "faces_count": faces_count}, + ) + try: + await NatsClient.publish(NatsSubjects.AUDIT_EVENT, msg.model_dump_json().encode("utf-8")) + except Exception as exc: + logger.warning("Failed to publish audit for photo %s: %s", event.photo_id, exc) + + @staticmethod + async def _schedule_cleanup(image_ref: str) -> None: + payload = json.dumps({"storage_keys": [image_ref]}).encode("utf-8") + try: + await NatsClient.publish(NatsSubjects.FINAL_BUCKET_CLEANUP, payload) + logger.info("Scheduled cleanup for %s", image_ref) + except Exception as exc: + logger.warning("Failed to schedule cleanup for %s: %s", image_ref, exc) + + @staticmethod + def _parse_event(raw_data: bytes) -> PhotoProcessEvent | None: + try: + return PhotoProcessEvent.model_validate_json(raw_data) + except Exception as exc: + logger.warning("Failed to parse photo process event: %s", exc) + return None + + async def _load_image(self, image_ref: str) -> FaceImagePayload: + bucket_name, object_name = self._parse_minio_ref(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 + + @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 + + +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) + + single_face_service = SingleFaceMatchService( + conn=conn, + photo_face_querier=container.photo_face_querier, + photo_querier=container.photo_querier, + user_match_service=container.auth_service, + user_notification_service=container.user_notifications_service, + ) + + worker = PhotoWorker( + conn=conn, + face_embedding_service=container.face_embedding_service, + single_face_service=single_face_service, + user_notification_service=container.user_notifications_service, + photo_face_querier=container.photo_face_querier, + photo_querier=container.photo_querier, + processing_job_querier=container.processing_job_querier, + ) + + await NatsClient.js_subscribe( + subject=NatsSubjects.PHOTO_PROCESS, + callback=worker.handle_message, + stream_name=worker_settings.stream_name, + durable_name=worker_settings.durable_name, + ) + + logger.info("PhotoWorker subscribed on %s; waiting for jobs", NatsSubjects.PHOTO_PROCESS.value) + 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/app/worker/photo_worker/schema/__init__.py b/app/worker/photo_worker/schema/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/worker/photo_worker/schema/event.py b/app/worker/photo_worker/schema/event.py new file mode 100644 index 0000000..d36802b --- /dev/null +++ b/app/worker/photo_worker/schema/event.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from pydantic import BaseModel, Field + + +class PhotoProcessEvent(BaseModel): + photo_id: uuid.UUID + image_ref: str + event_id: uuid.UUID | None = None + submitted_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + + model_config = {"extra": "allow"} diff --git a/app/worker/photo_worker/settings.py b/app/worker/photo_worker/settings.py new file mode 100644 index 0000000..b632fc9 --- /dev/null +++ b/app/worker/photo_worker/settings.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic import Field +from pydantic_settings import BaseSettings + + +class PhotoWorkerSettings(BaseSettings): + stream_name: str = Field("photo_processing") + durable_name: str = Field("photo_processing_worker") + similarity_threshold: float = Field(0.5) + + class Config: + env_prefix = "PHOTO_WORKER_" + + +settings = PhotoWorkerSettings() # type: ignore diff --git a/app/worker/photo_worker/tests/__init__.py b/app/worker/photo_worker/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/worker/photo_worker/tests/test_photo_worker.py b/app/worker/photo_worker/tests/test_photo_worker.py new file mode 100644 index 0000000..eecda3a --- /dev/null +++ b/app/worker/photo_worker/tests/test_photo_worker.py @@ -0,0 +1,372 @@ +import json +import sys +import uuid +from unittest.mock import AsyncMock, MagicMock, patch, create_autospec + +import pytest + +sys.modules["db.generated.user"] = MagicMock() +sys.modules["app.worker.notification.settings"] = MagicMock() +sys.modules["app.worker.notification.notification_queue"] = MagicMock() + +from app.service.face_embedding import DetectedFace, FaceEmbeddingService, FaceImagePayload # noqa: E402 +from app.service.face_match import SingleFaceMatchService # noqa: E402 +from app.service.user_notification import UserNotificationService # noqa: E402 +from app.worker.photo_worker.main import PhotoWorker # noqa: E402 +from app.worker.photo_worker.schema.event import PhotoProcessEvent # noqa: E402 +from db.generated import photo_faces as photo_face_queries # noqa: E402 + + +# ── fixtures ────────────────────────────────────────────────────────── + + +@pytest.fixture +def conn() -> MagicMock: + return MagicMock() + + +@pytest.fixture +def face_service() -> AsyncMock: + return create_autospec(FaceEmbeddingService, instance=True) + + +@pytest.fixture +def single_face_service() -> AsyncMock: + svc = create_autospec(SingleFaceMatchService, instance=True) + svc.process_detected_face = AsyncMock() + return svc + + +@pytest.fixture +def notification_service() -> AsyncMock: + svc = MagicMock(spec=UserNotificationService) + svc.create_notification = AsyncMock() + return svc + + +@pytest.fixture +def photo_face_querier() -> AsyncMock: + return create_autospec(photo_face_queries.AsyncQuerier, instance=True) + + +@pytest.fixture +def photo_querier() -> AsyncMock: + from db.generated import photos as photo_queries_mod + q = create_autospec(photo_queries_mod.AsyncQuerier, instance=True) + q.update_photo_status = AsyncMock(return_value=None) + q.update_photo_visibility = AsyncMock(return_value=None) + return q + + +@pytest.fixture +def worker( + conn: MagicMock, + face_service: AsyncMock, + single_face_service: AsyncMock, + notification_service: AsyncMock, + photo_face_querier: AsyncMock, + photo_querier: AsyncMock, +) -> PhotoWorker: + return PhotoWorker( + conn=conn, + face_embedding_service=face_service, + single_face_service=single_face_service, + user_notification_service=notification_service, + photo_face_querier=photo_face_querier, + photo_querier=photo_querier, + ) + + +@pytest.fixture +def event() -> PhotoProcessEvent: + return PhotoProcessEvent( + photo_id=uuid.uuid4(), + image_ref="photos/test.jpg", + event_id=uuid.uuid4(), + ) + + +def _make_face(embedding: list[float] | None = None) -> DetectedFace: + return DetectedFace( + embedding=embedding or [0.1] * 512, + bbox=(10.0, 20.0, 100.0, 200.0), + ) + + +def _event_bytes(event: PhotoProcessEvent) -> bytes: + return event.model_dump_json().encode() + + +# ── tests ───────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_invalid_payload_is_skipped(worker: PhotoWorker) -> None: + """Malformed JSON should be silently skipped.""" + await worker.handle_message(b"not json") + # No exceptions raised + + +@pytest.mark.asyncio +async def test_no_faces_skips_processing( + worker: PhotoWorker, + face_service: AsyncMock, + single_face_service: AsyncMock, + photo_face_querier: AsyncMock, + event: PhotoProcessEvent, +) -> None: + """If no faces detected, neither single nor group path runs.""" + face_service.detect_faces = AsyncMock(return_value=[]) + + with patch.object(worker, "_load_image", new_callable=AsyncMock) as mock_load: + mock_load.return_value = FaceImagePayload( + filename="test.jpg", content_type="image/jpeg", bytes=b"img" + ) + await worker.handle_message(_event_bytes(event)) + + single_face_service.process_detected_face.assert_not_called() + photo_face_querier.insert_photo_face_with_approval.assert_not_called() + + +@pytest.mark.asyncio +async def test_single_face_takes_single_path( + worker: PhotoWorker, + face_service: AsyncMock, + single_face_service: AsyncMock, + photo_face_querier: AsyncMock, + event: PhotoProcessEvent, +) -> None: + """Exactly 1 face -> single face match path.""" + face_service.detect_faces = AsyncMock(return_value=[_make_face()]) + + with patch.object(worker, "_load_image", new_callable=AsyncMock) as mock_load: + mock_load.return_value = FaceImagePayload( + filename="test.jpg", content_type="image/jpeg", bytes=b"img" + ) + await worker.handle_message(_event_bytes(event)) + + single_face_service.process_detected_face.assert_called_once() + photo_face_querier.insert_photo_face_with_approval.assert_not_called() + + +@pytest.mark.asyncio +async def test_multiple_faces_takes_group_path( + worker: PhotoWorker, + face_service: AsyncMock, + single_face_service: AsyncMock, + photo_face_querier: AsyncMock, + event: PhotoProcessEvent, +) -> None: + """Multiple faces -> group photo approval path.""" + faces = [_make_face([0.1] * 512), _make_face([0.2] * 512), _make_face([0.3] * 512)] + face_service.detect_faces = AsyncMock(return_value=faces) + photo_face_querier.insert_photo_face_with_approval = AsyncMock(return_value=None) + + with patch.object(worker, "_load_image", new_callable=AsyncMock) as mock_load: + mock_load.return_value = FaceImagePayload( + filename="test.jpg", content_type="image/jpeg", bytes=b"img" + ) + await worker.handle_message(_event_bytes(event)) + + single_face_service.process_detected_face.assert_not_called() + assert photo_face_querier.insert_photo_face_with_approval.call_count == 3 + + +@pytest.mark.asyncio +async def test_group_photo_sends_notification_on_match( + worker: PhotoWorker, + face_service: AsyncMock, + notification_service: AsyncMock, + photo_face_querier: AsyncMock, + event: PhotoProcessEvent, +) -> None: + """When a group face matches a user, a notification is sent.""" + faces = [_make_face(), _make_face()] + face_service.detect_faces = AsyncMock(return_value=faces) + + matched_approval = MagicMock() + matched_approval.user_id = uuid.uuid4() + matched_approval.photo_id = event.photo_id + + # First face matches, second doesn't + photo_face_querier.insert_photo_face_with_approval = AsyncMock( + side_effect=[matched_approval, None] + ) + + with patch.object(worker, "_load_image", new_callable=AsyncMock) as mock_load: + mock_load.return_value = FaceImagePayload( + filename="test.jpg", content_type="image/jpeg", bytes=b"img" + ) + await worker.handle_message(_event_bytes(event)) + + notification_service.create_notification.assert_called_once() + call_kwargs = notification_service.create_notification.call_args.kwargs + assert call_kwargs["type"] == "photo_approval" + assert call_kwargs["user_id"] == matched_approval.user_id + + +@pytest.mark.asyncio +async def test_group_photo_stores_bbox( + worker: PhotoWorker, + face_service: AsyncMock, + photo_face_querier: AsyncMock, + event: PhotoProcessEvent, +) -> None: + """Group path should pass bbox JSON to the DB query.""" + face = _make_face() + face_service.detect_faces = AsyncMock(return_value=[face, face]) + photo_face_querier.insert_photo_face_with_approval = AsyncMock(return_value=None) + + with patch.object(worker, "_load_image", new_callable=AsyncMock) as mock_load: + mock_load.return_value = FaceImagePayload( + filename="test.jpg", content_type="image/jpeg", bytes=b"img" + ) + await worker.handle_message(_event_bytes(event)) + + call_args = photo_face_querier.insert_photo_face_with_approval.call_args_list[0] + params = call_args.args[0] + bbox = json.loads(params.bbox) + assert bbox == {"x1": 10.0, "y1": 20.0, "x2": 100.0, "y2": 200.0} + + +@pytest.mark.asyncio +async def test_single_face_passes_correct_bbox( + worker: PhotoWorker, + face_service: AsyncMock, + single_face_service: AsyncMock, + event: PhotoProcessEvent, +) -> None: + """Single path should construct BBoxPayload from detected face.""" + face = _make_face() + face_service.detect_faces = AsyncMock(return_value=[face]) + + with patch.object(worker, "_load_image", new_callable=AsyncMock) as mock_load: + mock_load.return_value = FaceImagePayload( + filename="test.jpg", content_type="image/jpeg", bytes=b"img" + ) + await worker.handle_message(_event_bytes(event)) + + call_args = single_face_service.process_detected_face.call_args + bbox = call_args.args[2] + assert bbox.x1 == 10.0 + assert bbox.y1 == 20.0 + assert bbox.x2 == 100.0 + assert bbox.y2 == 200.0 + + +@pytest.mark.asyncio +async def test_image_load_failure_is_handled( + worker: PhotoWorker, + face_service: AsyncMock, + event: PhotoProcessEvent, +) -> None: + """If image fetch fails, worker should not crash.""" + with patch.object(worker, "_load_image", new_callable=AsyncMock) as mock_load: + mock_load.side_effect = RuntimeError("MinIO down") + await worker.handle_message(_event_bytes(event)) + + face_service.detect_faces.assert_not_called() + + +# ── cleanup scheduling tests ────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_cleanup_scheduled_after_single_face( + worker: PhotoWorker, + face_service: AsyncMock, + single_face_service: AsyncMock, + event: PhotoProcessEvent, +) -> None: + """After single face processing, both audit and cleanup events should be published.""" + face_service.detect_faces = AsyncMock(return_value=[_make_face()]) + + with ( + patch.object(worker, "_load_image", new_callable=AsyncMock) as mock_load, + patch("app.worker.photo_worker.main.NatsClient") as mock_nats, + ): + mock_nats.publish = AsyncMock() + mock_load.return_value = FaceImagePayload( + filename="test.jpg", content_type="image/jpeg", bytes=b"img" + ) + await worker.handle_message(_event_bytes(event)) + + from app.infra.nats import NatsSubjects + assert mock_nats.publish.call_count == 2 + audit_call = mock_nats.publish.call_args_list[0] + assert audit_call.args[0] == NatsSubjects.AUDIT_EVENT + cleanup_call = mock_nats.publish.call_args_list[1] + assert cleanup_call.args[0] == NatsSubjects.FINAL_BUCKET_CLEANUP + cleanup_payload = json.loads(cleanup_call.args[1]) + assert event.image_ref in cleanup_payload["storage_keys"] + + +@pytest.mark.asyncio +async def test_cleanup_scheduled_after_group_photo( + worker: PhotoWorker, + face_service: AsyncMock, + photo_face_querier: AsyncMock, + event: PhotoProcessEvent, +) -> None: + """After group photo processing, both audit and cleanup events should be published.""" + faces = [_make_face(), _make_face()] + face_service.detect_faces = AsyncMock(return_value=faces) + photo_face_querier.insert_photo_face_with_approval = AsyncMock(return_value=None) + + with ( + patch.object(worker, "_load_image", new_callable=AsyncMock) as mock_load, + patch("app.worker.photo_worker.main.NatsClient") as mock_nats, + ): + mock_nats.publish = AsyncMock() + mock_load.return_value = FaceImagePayload( + filename="test.jpg", content_type="image/jpeg", bytes=b"img" + ) + await worker.handle_message(_event_bytes(event)) + + from app.infra.nats import NatsSubjects + assert mock_nats.publish.call_count == 2 + cleanup_call = mock_nats.publish.call_args_list[1] + assert cleanup_call.args[0] == NatsSubjects.FINAL_BUCKET_CLEANUP + cleanup_payload = json.loads(cleanup_call.args[1]) + assert event.image_ref in cleanup_payload["storage_keys"] + + +@pytest.mark.asyncio +async def test_cleanup_scheduled_when_no_faces( + worker: PhotoWorker, + face_service: AsyncMock, + event: PhotoProcessEvent, +) -> None: + """Even if no faces detected, cleanup should still be scheduled.""" + face_service.detect_faces = AsyncMock(return_value=[]) + + with ( + patch.object(worker, "_load_image", new_callable=AsyncMock) as mock_load, + patch("app.worker.photo_worker.main.NatsClient") as mock_nats, + ): + mock_nats.publish = AsyncMock() + mock_load.return_value = FaceImagePayload( + filename="test.jpg", content_type="image/jpeg", bytes=b"img" + ) + await worker.handle_message(_event_bytes(event)) + + mock_nats.publish.assert_called_once() + cleanup_payload = json.loads(mock_nats.publish.call_args.args[1]) + assert event.image_ref in cleanup_payload["storage_keys"] + + +@pytest.mark.asyncio +async def test_no_cleanup_on_image_load_failure( + worker: PhotoWorker, + event: PhotoProcessEvent, +) -> None: + """If image fails to load, no cleanup should be scheduled.""" + with ( + patch.object(worker, "_load_image", new_callable=AsyncMock) as mock_load, + patch("app.worker.photo_worker.main.NatsClient") as mock_nats, + ): + mock_nats.publish = AsyncMock() + mock_load.side_effect = RuntimeError("MinIO down") + await worker.handle_message(_event_bytes(event)) + + mock_nats.publish.assert_not_called() 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..80ee87f 100644 --- a/app/worker/storage_cleaner/settings.py +++ b/app/worker/storage_cleaner/settings.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from typing import ClassVar + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +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: ClassVar[int] = 7 + + model_config = SettingsConfigDict(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[call-arg] diff --git a/app/worker/upload_group_worker/__init__.py b/app/worker/upload_group_worker/__init__.py new file mode 100644 index 0000000..4e74886 --- /dev/null +++ b/app/worker/upload_group_worker/__init__.py @@ -0,0 +1 @@ +"""Upload group import worker package.""" diff --git a/app/worker/upload_group_worker/main.py b/app/worker/upload_group_worker/main.py new file mode 100644 index 0000000..3be7275 --- /dev/null +++ b/app/worker/upload_group_worker/main.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import asyncio + +from pydantic import ValidationError + +from app.container import Container +from app.core.config import settings as app_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 +from app.infra.redis import RedisClient +from app.worker.upload_group_worker.schema.event import UploadGroupImportRequestedEvent +from app.worker.upload_group_worker.settings import settings + + +async def _handle_message(data: bytes) -> None: + try: + event = UploadGroupImportRequestedEvent.model_validate_json(data) + except ValidationError as exc: + logger.warning("Invalid upload group import payload: %s", exc) + return + + async with engine.begin() as conn: + container = Container(conn) + await container.upload_requests_service.process_group_import( + group_id=event.group_id, + visibility=event.visibility, + day_number=event.day_number, + ) + + +async def main() -> None: + try: + RedisClient.get_instance() + except RuntimeError: + RedisClient.init( + host=app_settings.REDIS_HOST, + port=app_settings.REDIS_PORT, + password=app_settings.REDIS_PASSWORD, + ) + + await init_minio_client( + minio_host=app_settings.MINIO_HOST, + minio_port=app_settings.MINIO_API_PORT, + minio_root_user=app_settings.MINIO_ROOT_USER, + minio_root_password=app_settings.MINIO_ROOT_PASSWORD, + ) + + await NatsClient.connect() + try: + await NatsClient.js_subscribe( + subject=settings.subject_enum, + callback=_handle_message, + stream_name=settings.stream_name, + durable_name=settings.durable_name, + ) + logger.info("UploadGroupWorker subscribed on %s", settings.subject) + 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(main()) diff --git a/app/worker/upload_group_worker/schema/__init__.py b/app/worker/upload_group_worker/schema/__init__.py new file mode 100644 index 0000000..b58e6fe --- /dev/null +++ b/app/worker/upload_group_worker/schema/__init__.py @@ -0,0 +1 @@ +"""Schemas for the upload group import worker.""" diff --git a/app/worker/upload_group_worker/schema/event.py b/app/worker/upload_group_worker/schema/event.py new file mode 100644 index 0000000..7527aef --- /dev/null +++ b/app/worker/upload_group_worker/schema/event.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import uuid +from datetime import datetime, timezone + +from pydantic import BaseModel, Field + + +class UploadGroupImportRequestedEvent(BaseModel): + group_id: uuid.UUID + event_id: uuid.UUID + folder_id: str + requested_by: uuid.UUID + visibility: str + day_number: int | None = None + submitted_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/app/worker/upload_group_worker/settings.py b/app/worker/upload_group_worker/settings.py new file mode 100644 index 0000000..94dfc26 --- /dev/null +++ b/app/worker/upload_group_worker/settings.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + +from app.core.constant import ( + UPLOAD_GROUP_IMPORT_DURABLE_NAME, + UPLOAD_GROUP_IMPORT_STREAM, + UPLOAD_GROUP_IMPORT_SUBJECT, +) +from app.infra.nats import NatsSubjects + + +class UploadGroupWorkerSettings(BaseSettings): + subject: str = Field(UPLOAD_GROUP_IMPORT_SUBJECT) + stream_name: str = Field(UPLOAD_GROUP_IMPORT_STREAM) + durable_name: str = Field(UPLOAD_GROUP_IMPORT_DURABLE_NAME) + + model_config = SettingsConfigDict(env_prefix="UPLOAD_GROUP_WORKER_") + + @property + def subject_enum(self) -> NatsSubjects: + try: + return NatsSubjects(self.subject) + except ValueError: + return NatsSubjects.STAFF_UPLOAD_GROUP_IMPORT_REQUESTED + + +settings = UploadGroupWorkerSettings() # type: ignore[call-arg] diff --git a/db/generated/devices.py b/db/generated/devices.py index 2514ff9..fb2e064 100644 --- a/db/generated/devices.py +++ b/db/generated/devices.py @@ -37,7 +37,7 @@ ) VALUES ( COALESCE(:p1, uuid_generate_v4()), :p2, :p3, :p4, :p5 ) -RETURNING id, user_id, device_name, device_type, push_token, totp_secret, is_active, is_invalid_token, is_2fa_enabled, last_active, created_at +RETURNING id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token """ @@ -68,13 +68,13 @@ class CreateDeviceParams: GET_DEVICE__BY_ID = """-- name: get_device__by_id \\:one -SELECT id, user_id, device_name, device_type, push_token, totp_secret, is_active, is_invalid_token, is_2fa_enabled, last_active, created_at from user_devices +SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token from user_devices WHERE id =:p1 """ LIST_USER_DEVICES = """-- name: list_user_devices \\:many -SELECT id, user_id, device_name, device_type, push_token, totp_secret, is_active, is_invalid_token, is_2fa_enabled, last_active, created_at +SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token FROM user_devices WHERE user_id = :p1 ORDER BY last_active DESC @@ -112,7 +112,7 @@ class CreateDeviceParams: is_invalid_token = FALSE WHERE id = :p1 AND user_id = :p3 -RETURNING id, user_id, device_name, device_type, push_token, totp_secret, is_active, is_invalid_token, is_2fa_enabled, last_active, created_at +RETURNING id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token """ @@ -144,13 +144,13 @@ async def create_device(self, arg: CreateDeviceParams) -> Optional[models.UserDe user_id=row[1], device_name=row[2], device_type=row[3], - push_token=row[4], - totp_secret=row[5], - is_active=row[6], - is_invalid_token=row[7], - is_2fa_enabled=row[8], - last_active=row[9], - created_at=row[10], + totp_secret=row[4], + is_2fa_enabled=row[5], + last_active=row[6], + created_at=row[7], + push_token=row[8], + is_active=row[9], + is_invalid_token=row[10], ) async def deactivate_device(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None: @@ -168,13 +168,13 @@ async def get_device__by_id(self, *, id: uuid.UUID) -> Optional[models.UserDevic user_id=row[1], device_name=row[2], device_type=row[3], - push_token=row[4], - totp_secret=row[5], - is_active=row[6], - is_invalid_token=row[7], - is_2fa_enabled=row[8], - last_active=row[9], - created_at=row[10], + totp_secret=row[4], + is_2fa_enabled=row[5], + last_active=row[6], + created_at=row[7], + push_token=row[8], + is_active=row[9], + is_invalid_token=row[10], ) async def list_user_devices(self, *, user_id: uuid.UUID) -> AsyncIterator[models.UserDevice]: @@ -185,13 +185,13 @@ async def list_user_devices(self, *, user_id: uuid.UUID) -> AsyncIterator[models user_id=row[1], device_name=row[2], device_type=row[3], - push_token=row[4], - totp_secret=row[5], - is_active=row[6], - is_invalid_token=row[7], - is_2fa_enabled=row[8], - last_active=row[9], - created_at=row[10], + totp_secret=row[4], + is_2fa_enabled=row[5], + last_active=row[6], + created_at=row[7], + push_token=row[8], + is_active=row[9], + is_invalid_token=row[10], ) async def mark_device_token_invalid(self, *, push_token: Optional[str]) -> None: @@ -212,11 +212,11 @@ async def update_device_push_token(self, *, id: uuid.UUID, push_token: Optional[ user_id=row[1], device_name=row[2], device_type=row[3], - push_token=row[4], - totp_secret=row[5], - is_active=row[6], - is_invalid_token=row[7], - is_2fa_enabled=row[8], - last_active=row[9], - created_at=row[10], + totp_secret=row[4], + is_2fa_enabled=row[5], + last_active=row[6], + created_at=row[7], + push_token=row[8], + is_active=row[9], + is_invalid_token=row[10], ) diff --git a/db/generated/models.py b/db/generated/models.py index db5740d..37d2f68 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -38,8 +38,8 @@ class ProcessingJobStatus(str, enum.Enum): class StaffRole(str, enum.Enum): ADMIN = "admin" - MULTI = "multi" MULTI_TEAM_LEAD = "multi_team_lead" + MULTI = "multi" class UploadRequestStatus(str, enum.Enum): @@ -193,6 +193,26 @@ class UploadRequest: approved_at: Optional[datetime.datetime] photo_count: int rejection_reason: Optional[str] + group_id: Optional[uuid.UUID] + + +@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 + processing_status: str + total_photo_count: int + batch_count: int + processed_photo_count: int + failed_photo_count: int + created_at: datetime.datetime + approved_at: Optional[datetime.datetime] + rejection_reason: Optional[str] + error_message: Optional[str] @dataclasses.dataclass() @@ -222,6 +242,7 @@ class User: display_name: Optional[str] face_embedding: Optional[Any] deleted_at: Optional[datetime.datetime] + blocked: bool @dataclasses.dataclass() @@ -230,13 +251,13 @@ class UserDevice: user_id: uuid.UUID device_name: Optional[str] device_type: Optional[str] - push_token: Optional[str] totp_secret: Optional[str] - is_active: bool - is_invalid_token: bool is_2fa_enabled: bool last_active: datetime.datetime created_at: datetime.datetime + push_token: Optional[str] + is_active: bool + is_invalid_token: bool @dataclasses.dataclass() diff --git a/db/generated/photo_approvals.py b/db/generated/photo_approvals.py new file mode 100644 index 0000000..3ef8a75 --- /dev/null +++ b/db/generated/photo_approvals.py @@ -0,0 +1,101 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.30.0 +# source: photo_approvals.sql +from typing import AsyncIterator, Optional +import uuid + +import sqlalchemy +import sqlalchemy.ext.asyncio + +from db.generated import models + + +CREATE_PHOTO_APPROVAL = """-- name: create_photo_approval \\:one +INSERT INTO photo_approvals ( + photo_id, + user_id, + decision +) VALUES ( + :p1, :p2, :p3 +) +RETURNING id, photo_id, user_id, decision, decided_at +""" + + +GET_PHOTO_APPROVALS_BY_PHOTO_ID = """-- name: get_photo_approvals_by_photo_id \\:many +SELECT id, photo_id, user_id, decision, decided_at FROM photo_approvals WHERE photo_id = :p1 +""" + + +LIST_APPROVALS_BY_USER_AND_STATUS = """-- name: list_approvals_by_user_and_status \\:many +SELECT id, photo_id, user_id, decision, decided_at FROM photo_approvals +WHERE user_id = :p1 + AND (:p2\\:\\:varchar IS NULL OR decision = :p2) +ORDER BY decided_at DESC +LIMIT :p3 OFFSET :p4 +""" + + +UPDATE_PHOTO_APPROVAL_DECISION = """-- name: update_photo_approval_decision \\:one +UPDATE photo_approvals +SET decision = :p2, decided_at = now() +WHERE photo_id = :p1 AND user_id = :p3 +RETURNING id, photo_id, user_id, decision, decided_at +""" + + +class AsyncQuerier: + def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): + self._conn = conn + + async def create_photo_approval(self, *, photo_id: uuid.UUID, user_id: uuid.UUID, decision: str) -> Optional[models.PhotoApproval]: + row = (await self._conn.execute(sqlalchemy.text(CREATE_PHOTO_APPROVAL), {"p1": photo_id, "p2": user_id, "p3": decision})).first() + if row is None: + return None + return models.PhotoApproval( + id=row[0], + photo_id=row[1], + user_id=row[2], + decision=row[3], + decided_at=row[4], + ) + + async def get_photo_approvals_by_photo_id(self, *, photo_id: uuid.UUID) -> AsyncIterator[models.PhotoApproval]: + result = await self._conn.stream(sqlalchemy.text(GET_PHOTO_APPROVALS_BY_PHOTO_ID), {"p1": photo_id}) + async for row in result: + yield models.PhotoApproval( + id=row[0], + photo_id=row[1], + user_id=row[2], + decision=row[3], + decided_at=row[4], + ) + + async def list_approvals_by_user_and_status(self, *, user_id: uuid.UUID, dollar_2: str, limit: int, offset: int) -> AsyncIterator[models.PhotoApproval]: + result = await self._conn.stream(sqlalchemy.text(LIST_APPROVALS_BY_USER_AND_STATUS), { + "p1": user_id, + "p2": dollar_2, + "p3": limit, + "p4": offset, + }) + async for row in result: + yield models.PhotoApproval( + id=row[0], + photo_id=row[1], + user_id=row[2], + decision=row[3], + decided_at=row[4], + ) + + async def update_photo_approval_decision(self, *, photo_id: uuid.UUID, decision: str, user_id: uuid.UUID) -> Optional[models.PhotoApproval]: + row = (await self._conn.execute(sqlalchemy.text(UPDATE_PHOTO_APPROVAL_DECISION), {"p1": photo_id, "p2": decision, "p3": user_id})).first() + if row is None: + return None + return models.PhotoApproval( + id=row[0], + photo_id=row[1], + user_id=row[2], + decision=row[3], + decided_at=row[4], + ) diff --git a/db/generated/photo_faces.py b/db/generated/photo_faces.py new file mode 100644 index 0000000..507e6c6 --- /dev/null +++ b/db/generated/photo_faces.py @@ -0,0 +1,262 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.30.0 +# source: photo_faces.sql +import dataclasses +from typing import Any, Optional +import uuid + +import sqlalchemy +import sqlalchemy.ext.asyncio + +from db.generated import models + + +INSERT_PHOTO_FACE_WITH_APPROVAL = """-- name: insert_photo_face_with_approval \\:one +WITH matched_user AS ( + SELECT id AS user_id + FROM users + WHERE face_embedding IS NOT NULL + AND deleted_at IS NULL + AND face_embedding <=> :p3\\:\\:vector <= :p4 + ORDER BY face_embedding <=> :p3\\:\\:vector ASC + LIMIT 1 +), +insert_face AS ( + INSERT INTO photo_faces (photo_id, face_index, embedding, bbox) + VALUES (:p1, :p2, :p3\\:\\:vector, :p5) + ON CONFLICT (photo_id, face_index) + DO UPDATE SET embedding = EXCLUDED.embedding, + bbox = EXCLUDED.bbox + RETURNING id, photo_id, face_index +), +matched AS ( + SELECT insert_face.photo_id, matched_user.user_id + FROM insert_face, matched_user + WHERE matched_user.user_id IS NOT NULL +) +INSERT INTO photo_approvals (photo_id, user_id, decision) +SELECT photo_id, user_id, :p6 +FROM matched +RETURNING id, photo_id, user_id, decision, decided_at +""" + + +@dataclasses.dataclass() +class InsertPhotoFaceWithApprovalParams: + photo_id: uuid.UUID + face_index: int + column_3: Any + face_embedding: Optional[Any] + bbox: Optional[str] + decision: str + + +PHOTO_FACES_ENSURE_FACE_MATCH = """-- name: photo_faces_ensure_face_match \\:one +WITH upserted_photo_face AS ( + INSERT INTO photo_faces ( + photo_id, + face_index, + embedding, + bbox + ) VALUES ( + :p1, + :p2, + CAST(:p3 AS vector), + :p4 + ) ON CONFLICT (photo_id, face_index) + DO UPDATE SET embedding = EXCLUDED.embedding, + bbox = EXCLUDED.bbox + RETURNING id, photo_id +), +existing_match AS ( + SELECT 1 + FROM face_matches fm + JOIN photo_faces pf ON pf.id = fm.photo_face_id + WHERE pf.photo_id = :p1 + LIMIT 1 +), +inserted_match AS ( + INSERT INTO face_matches (photo_face_id, user_id, confidence) + SELECT upserted_photo_face.id, :p5, :p6 + WHERE NOT EXISTS (SELECT 1 FROM existing_match) + RETURNING id +) +SELECT upserted_photo_face.id AS photo_face_id, + inserted_match.id AS face_match_id +FROM upserted_photo_face +LEFT JOIN inserted_match ON TRUE +""" + + +@dataclasses.dataclass() +class PhotoFacesEnsureFaceMatchParams: + photo_id: uuid.UUID + face_index: int + column_3: Any + bbox: Optional[str] + user_id: uuid.UUID + confidence: float + + +@dataclasses.dataclass() +class PhotoFacesEnsureFaceMatchRow: + photo_face_id: uuid.UUID + face_match_id: Optional[uuid.UUID] + + +PHOTO_FACES_FIND_CLOSEST_USER = """-- name: photo_faces_find_closest_user \\:one +SELECT id, + (face_embedding <=> CAST(:p1 AS vector)) AS distance +FROM users +WHERE face_embedding IS NOT NULL +ORDER BY distance ASC +LIMIT 1 +""" + + +@dataclasses.dataclass() +class PhotoFacesFindClosestUserRow: + id: uuid.UUID + distance: Optional[Any] + + +PHOTO_FACES_MATCH_EXISTS_FOR_PHOTO = """-- name: photo_faces_match_exists_for_photo \\:one +SELECT 1 +FROM face_matches fm +JOIN photo_faces pf ON pf.id = fm.photo_face_id +WHERE pf.photo_id = :p1 +LIMIT 1 +""" + + +PHOTO_FACES_MATCH_EXISTS_FOR_PHOTO_FACE = """-- name: photo_faces_match_exists_for_photo_face \\:one +SELECT 1 +FROM face_matches +WHERE photo_face_id = :p1 +LIMIT 1 +""" + + +PHOTO_FACES_PHOTO_EXISTS = """-- name: photo_faces_photo_exists \\:one +SELECT 1 +FROM photos +WHERE id = :p1 +LIMIT 1 +""" + + +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 +""" + + +USER_HAS_FACE_MATCH_FOR_PHOTO = """-- name: user_has_face_match_for_photo \\:one +SELECT 1 +FROM face_matches fm +JOIN photo_faces pf ON pf.id = fm.photo_face_id +WHERE pf.photo_id = :p1 AND fm.user_id = :p2 +LIMIT 1 +""" + + +class AsyncQuerier: + def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): + self._conn = conn + + async def insert_photo_face_with_approval(self, arg: InsertPhotoFaceWithApprovalParams) -> Optional[models.PhotoApproval]: + row = (await self._conn.execute(sqlalchemy.text(INSERT_PHOTO_FACE_WITH_APPROVAL), { + "p1": arg.photo_id, + "p2": arg.face_index, + "p3": arg.column_3, + "p4": arg.face_embedding, + "p5": arg.bbox, + "p6": arg.decision, + })).first() + if row is None: + return None + return models.PhotoApproval( + id=row[0], + photo_id=row[1], + user_id=row[2], + decision=row[3], + decided_at=row[4], + ) + + async def photo_faces_ensure_face_match(self, arg: PhotoFacesEnsureFaceMatchParams) -> Optional[PhotoFacesEnsureFaceMatchRow]: + row = (await self._conn.execute(sqlalchemy.text(PHOTO_FACES_ENSURE_FACE_MATCH), { + "p1": arg.photo_id, + "p2": arg.face_index, + "p3": arg.column_3, + "p4": arg.bbox, + "p5": arg.user_id, + "p6": arg.confidence, + })).first() + if row is None: + return None + return PhotoFacesEnsureFaceMatchRow( + photo_face_id=row[0], + face_match_id=row[1], + ) + + async def photo_faces_find_closest_user(self, *, dollar_1: Any) -> Optional[PhotoFacesFindClosestUserRow]: + row = (await self._conn.execute(sqlalchemy.text(PHOTO_FACES_FIND_CLOSEST_USER), {"p1": dollar_1})).first() + if row is None: + return None + return PhotoFacesFindClosestUserRow( + id=row[0], + distance=row[1], + ) + + async def photo_faces_match_exists_for_photo(self, *, photo_id: uuid.UUID) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(PHOTO_FACES_MATCH_EXISTS_FOR_PHOTO), {"p1": photo_id})).first() + if row is None: + return None + return row[0] + + async def photo_faces_match_exists_for_photo_face(self, *, photo_face_id: uuid.UUID) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(PHOTO_FACES_MATCH_EXISTS_FOR_PHOTO_FACE), {"p1": photo_face_id})).first() + if row is None: + return None + return row[0] + + async def photo_faces_photo_exists(self, *, id: uuid.UUID) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(PHOTO_FACES_PHOTO_EXISTS), {"p1": id})).first() + if row is None: + return None + return row[0] + + 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], + ) + + async def user_has_face_match_for_photo(self, *, photo_id: uuid.UUID, user_id: uuid.UUID) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(USER_HAS_FACE_MATCH_FOR_PHOTO), {"p1": photo_id, "p2": user_id})).first() + if row is None: + return None + return row[0] diff --git a/db/generated/photos.py b/db/generated/photos.py index 52aa75d..2c14a10 100644 --- a/db/generated/photos.py +++ b/db/generated/photos.py @@ -4,7 +4,7 @@ # source: photos.sql import dataclasses import datetime -from typing import Optional +from typing import Any, AsyncIterator, Optional import uuid import sqlalchemy @@ -13,6 +13,18 @@ from db.generated import models +COUNT_EVENT_PHOTOS_FOR_USER = """-- name: count_event_photos_for_user \\:one +SELECT COUNT(DISTINCT p.id) +FROM photos p +LEFT JOIN photo_faces pf ON pf.photo_id = p.id +LEFT JOIN face_matches fm ON fm.photo_face_id = pf.id AND fm.user_id = :p1 +LEFT JOIN photo_approvals pa ON pa.photo_id = p.id AND pa.user_id = :p1 +WHERE p.event_id = :p2 + AND p.status = 'approved' + AND (p.visibility = 'public' OR fm.user_id = :p1 OR pa.user_id = :p1) +""" + + CREATE_PHOTO = """-- name: create_photo \\:one INSERT INTO photos ( event_id, @@ -36,10 +48,94 @@ class CreatePhotoParams: visibility: str +GET_DRIVE_FILE_ID_FOR_PHOTO = """-- name: get_drive_file_id_for_photo \\:one +SELECT urp.drive_file_id +FROM upload_request_photos urp +WHERE urp.final_storage_key = :p1 +LIMIT 1 +""" + + +GET_PHOTO_BY_ID = """-- name: get_photo_by_id \\:one +SELECT id, event_id, uploaded_by, storage_key, taken_at, day_number, visibility, status, created_at FROM photos WHERE id = :p1 +""" + + +LIST_EVENT_PHOTOS_FOR_USER = """-- name: list_event_photos_for_user \\:many +SELECT DISTINCT p.id, p.event_id, p.uploaded_by, p.storage_key, p.taken_at, p.day_number, p.visibility, p.status, p.created_at +FROM photos p +LEFT JOIN photo_faces pf ON pf.photo_id = p.id +LEFT JOIN face_matches fm ON fm.photo_face_id = pf.id AND fm.user_id = :p1 +LEFT JOIN photo_approvals pa ON pa.photo_id = p.id AND pa.user_id = :p1 +WHERE p.event_id = :p2 + AND p.status = 'approved' + AND (p.visibility = 'public' OR fm.user_id = :p1 OR pa.user_id = :p1) +ORDER BY + CASE WHEN :p3 = 'asc' THEN p.created_at END ASC, + CASE WHEN :p3 != 'asc' THEN p.created_at END DESC +LIMIT :p4 OFFSET :p5 +""" + + +@dataclasses.dataclass() +class ListEventPhotosForUserParams: + user_id: uuid.UUID + event_id: uuid.UUID + column_3: Optional[Any] + limit: int + offset: int + + +LIST_USER_PHOTOS = """-- name: list_user_photos \\:many +SELECT DISTINCT p.id, p.event_id, p.uploaded_by, p.storage_key, p.taken_at, p.day_number, p.visibility, p.status, p.created_at +FROM photos p +LEFT JOIN photo_faces pf ON pf.photo_id = p.id +LEFT JOIN face_matches fm ON fm.photo_face_id = pf.id AND fm.user_id = :p1 +LEFT JOIN photo_approvals pa ON pa.photo_id = p.id AND pa.user_id = :p1 +WHERE (fm.user_id = :p1 OR pa.user_id = :p1) + AND (:p2\\:\\:uuid IS NULL OR p.event_id = :p2) +ORDER BY + CASE WHEN :p3 = 'asc' THEN p.created_at END ASC, + CASE WHEN :p3 != 'asc' THEN p.created_at END DESC +LIMIT :p4 OFFSET :p5 +""" + + +@dataclasses.dataclass() +class ListUserPhotosParams: + user_id: uuid.UUID + column_2: uuid.UUID + column_3: Optional[Any] + limit: int + offset: int + + +UPDATE_PHOTO_STATUS = """-- name: update_photo_status \\:one +UPDATE photos +SET status = :p2 +WHERE id = :p1 +RETURNING id, event_id, uploaded_by, storage_key, taken_at, day_number, visibility, status, created_at +""" + + +UPDATE_PHOTO_VISIBILITY = """-- name: update_photo_visibility \\:one +UPDATE photos +SET visibility = :p2 +WHERE id = :p1 +RETURNING id, event_id, uploaded_by, storage_key, taken_at, day_number, visibility, status, created_at +""" + + class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn + async def count_event_photos_for_user(self, *, user_id: uuid.UUID, event_id: uuid.UUID) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(COUNT_EVENT_PHOTOS_FOR_USER), {"p1": user_id, "p2": event_id})).first() + if row is None: + return None + return row[0] + async def create_photo(self, arg: CreatePhotoParams) -> Optional[models.Photo]: row = (await self._conn.execute(sqlalchemy.text(CREATE_PHOTO), { "p1": arg.event_id, @@ -61,3 +157,99 @@ async def create_photo(self, arg: CreatePhotoParams) -> Optional[models.Photo]: status=row[7], created_at=row[8], ) + + async def get_drive_file_id_for_photo(self, *, final_storage_key: Optional[str]) -> Optional[str]: + row = (await self._conn.execute(sqlalchemy.text(GET_DRIVE_FILE_ID_FOR_PHOTO), {"p1": final_storage_key})).first() + if row is None: + return None + return row[0] + + async def get_photo_by_id(self, *, id: uuid.UUID) -> Optional[models.Photo]: + row = (await self._conn.execute(sqlalchemy.text(GET_PHOTO_BY_ID), {"p1": id})).first() + if row is None: + return None + return models.Photo( + id=row[0], + event_id=row[1], + uploaded_by=row[2], + storage_key=row[3], + taken_at=row[4], + day_number=row[5], + visibility=row[6], + status=row[7], + created_at=row[8], + ) + + async def list_event_photos_for_user(self, arg: ListEventPhotosForUserParams) -> AsyncIterator[models.Photo]: + result = await self._conn.stream(sqlalchemy.text(LIST_EVENT_PHOTOS_FOR_USER), { + "p1": arg.user_id, + "p2": arg.event_id, + "p3": arg.column_3, + "p4": arg.limit, + "p5": arg.offset, + }) + async for row in result: + yield models.Photo( + id=row[0], + event_id=row[1], + uploaded_by=row[2], + storage_key=row[3], + taken_at=row[4], + day_number=row[5], + visibility=row[6], + status=row[7], + created_at=row[8], + ) + + async def list_user_photos(self, arg: ListUserPhotosParams) -> AsyncIterator[models.Photo]: + result = await self._conn.stream(sqlalchemy.text(LIST_USER_PHOTOS), { + "p1": arg.user_id, + "p2": arg.column_2, + "p3": arg.column_3, + "p4": arg.limit, + "p5": arg.offset, + }) + async for row in result: + yield models.Photo( + id=row[0], + event_id=row[1], + uploaded_by=row[2], + storage_key=row[3], + taken_at=row[4], + day_number=row[5], + visibility=row[6], + status=row[7], + created_at=row[8], + ) + + async def update_photo_status(self, *, id: uuid.UUID, status: Any) -> Optional[models.Photo]: + row = (await self._conn.execute(sqlalchemy.text(UPDATE_PHOTO_STATUS), {"p1": id, "p2": status})).first() + if row is None: + return None + return models.Photo( + id=row[0], + event_id=row[1], + uploaded_by=row[2], + storage_key=row[3], + taken_at=row[4], + day_number=row[5], + visibility=row[6], + status=row[7], + created_at=row[8], + ) + + async def update_photo_visibility(self, *, id: uuid.UUID, visibility: str) -> Optional[models.Photo]: + row = (await self._conn.execute(sqlalchemy.text(UPDATE_PHOTO_VISIBILITY), {"p1": id, "p2": visibility})).first() + if row is None: + return None + return models.Photo( + id=row[0], + event_id=row[1], + uploaded_by=row[2], + storage_key=row[3], + taken_at=row[4], + day_number=row[5], + visibility=row[6], + status=row[7], + created_at=row[8], + ) diff --git a/db/generated/processing_jobs.py b/db/generated/processing_jobs.py new file mode 100644 index 0000000..d2d4d80 --- /dev/null +++ b/db/generated/processing_jobs.py @@ -0,0 +1,83 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.30.0 +# source: processing_jobs.sql +from typing import Any, Optional +import uuid + +import sqlalchemy +import sqlalchemy.ext.asyncio + +from db.generated import models + + +CREATE_PROCESSING_JOB = """-- name: create_processing_job \\:one +INSERT INTO processing_jobs (photo_id, job_type, status) +VALUES (:p1, :p2, 'pending') +RETURNING id, photo_id, job_type, status, attempts, created_at, completed_at +""" + + +GET_PROCESSING_JOB_BY_PHOTO_ID = """-- name: get_processing_job_by_photo_id \\:one +SELECT id, photo_id, job_type, status, attempts, created_at, completed_at FROM processing_jobs +WHERE photo_id = :p1 +ORDER BY created_at DESC +LIMIT 1 +""" + + +UPDATE_PROCESSING_JOB_STATUS = """-- name: update_processing_job_status \\:one +UPDATE processing_jobs +SET status = :p2, + attempts = attempts + 1, + completed_at = CASE WHEN :p2 IN ('completed', 'failed') THEN now() ELSE completed_at END +WHERE id = :p1 +RETURNING id, photo_id, job_type, status, attempts, created_at, completed_at +""" + + +class AsyncQuerier: + def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): + self._conn = conn + + async def create_processing_job(self, *, photo_id: uuid.UUID, job_type: str) -> Optional[models.ProcessingJob]: + row = (await self._conn.execute(sqlalchemy.text(CREATE_PROCESSING_JOB), {"p1": photo_id, "p2": job_type})).first() + if row is None: + return None + return models.ProcessingJob( + id=row[0], + photo_id=row[1], + job_type=row[2], + status=row[3], + attempts=row[4], + created_at=row[5], + completed_at=row[6], + ) + + async def get_processing_job_by_photo_id(self, *, photo_id: uuid.UUID) -> Optional[models.ProcessingJob]: + row = (await self._conn.execute(sqlalchemy.text(GET_PROCESSING_JOB_BY_PHOTO_ID), {"p1": photo_id})).first() + if row is None: + return None + return models.ProcessingJob( + id=row[0], + photo_id=row[1], + job_type=row[2], + status=row[3], + attempts=row[4], + created_at=row[5], + completed_at=row[6], + ) + + async def update_processing_job_status(self, *, id: uuid.UUID, status: Any) -> Optional[models.ProcessingJob]: + row = (await self._conn.execute(sqlalchemy.text(UPDATE_PROCESSING_JOB_STATUS), {"p1": id, "p2": status})).first() + if row is None: + return None + return models.ProcessingJob( + id=row[0], + photo_id=row[1], + job_type=row[2], + status=row[3], + attempts=row[4], + created_at=row[5], + completed_at=row[6], + ) 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/staff_drive_connections.py b/db/generated/staff_drive_connections.py index b62fe1e..941e8b7 100644 --- a/db/generated/staff_drive_connections.py +++ b/db/generated/staff_drive_connections.py @@ -22,6 +22,15 @@ """ +GET_ANY_ACTIVE_STAFF_DRIVE_CONNECTION = """-- name: get_any_active_staff_drive_connection \\:one +SELECT id, staff_user_id, provider, google_email, google_account_id, access_token, refresh_token, token_expires_at, scopes, connected_at, revoked_at, created_at, updated_at +FROM staff_drive_connections +WHERE revoked_at IS NULL +ORDER BY connected_at DESC +LIMIT 1 +""" + + REVOKE_STAFF_DRIVE_CONNECTION_BY_STAFF_USER_ID = """-- name: revoke_staff_drive_connection_by_staff_user_id \\:exec UPDATE staff_drive_connections SET revoked_at = NOW(), @@ -99,6 +108,26 @@ async def get_active_staff_drive_connection_by_staff_user_id(self, *, staff_user updated_at=row[12], ) + async def get_any_active_staff_drive_connection(self) -> Optional[models.StaffDriveConnection]: + row = (await self._conn.execute(sqlalchemy.text(GET_ANY_ACTIVE_STAFF_DRIVE_CONNECTION))).first() + if row is None: + return None + return models.StaffDriveConnection( + id=row[0], + staff_user_id=row[1], + provider=row[2], + google_email=row[3], + google_account_id=row[4], + access_token=row[5], + refresh_token=row[6], + token_expires_at=row[7], + scopes=row[8], + connected_at=row[9], + revoked_at=row[10], + created_at=row[11], + updated_at=row[12], + ) + async def revoke_staff_drive_connection_by_staff_user_id(self, *, staff_user_id: uuid.UUID, provider: str) -> None: await self._conn.execute(sqlalchemy.text(REVOKE_STAFF_DRIVE_CONNECTION_BY_STAFF_USER_ID), {"p1": staff_user_id, "p2": provider}) diff --git a/db/generated/upload_request_groups.py b/db/generated/upload_request_groups.py new file mode 100644 index 0000000..617e34f --- /dev/null +++ b/db/generated/upload_request_groups.py @@ -0,0 +1,378 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.30.0 +# source: upload_request_groups.sql +import dataclasses +from typing import Any, AsyncIterator, Optional +import uuid + +import sqlalchemy +import sqlalchemy.ext.asyncio + +from db.generated import models + + +_RETURNING_COLUMNS = """ +RETURNING id, event_id, folder_id, requested_by, approved_by, status, processing_status, + total_photo_count, batch_count, processed_photo_count, failed_photo_count, + created_at, approved_at, rejection_reason, error_message +""" + + +APPROVE_UPLOAD_REQUEST_GROUP = f"""-- 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_COLUMNS} +""" + + +COMPLETE_UPLOAD_REQUEST_GROUP_PROCESSING = f"""-- name: complete_upload_request_group_processing \\:one +UPDATE upload_request_groups +SET processing_status = 'completed', + total_photo_count = :p2, + batch_count = :p3, + processed_photo_count = :p4, + failed_photo_count = :p5, + error_message = NULL +WHERE id = :p1 +{_RETURNING_COLUMNS} +""" + + +@dataclasses.dataclass() +class CompleteUploadRequestGroupProcessingParams: + id: uuid.UUID + total_photo_count: int + batch_count: int + processed_photo_count: int + failed_photo_count: int + + +CREATE_UPLOAD_REQUEST_GROUP = f"""-- 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_COLUMNS} +""" + + +@dataclasses.dataclass() +class CreateUploadRequestGroupParams: + event_id: uuid.UUID + folder_id: str + requested_by: uuid.UUID + total_photo_count: int + batch_count: int + + +DELETE_UPLOAD_REQUEST_GROUP = """-- name: delete_upload_request_group \\:exec +DELETE FROM upload_request_groups +WHERE id = :p1 +""" + + +FAIL_UPLOAD_REQUEST_GROUP_PROCESSING = f"""-- name: fail_upload_request_group_processing \\:one +UPDATE upload_request_groups +SET processing_status = 'failed', + total_photo_count = :p2, + batch_count = :p3, + processed_photo_count = :p4, + failed_photo_count = :p5, + error_message = :p6 +WHERE id = :p1 +{_RETURNING_COLUMNS} +""" + + +@dataclasses.dataclass() +class FailUploadRequestGroupProcessingParams: + id: uuid.UUID + total_photo_count: int + batch_count: int + processed_photo_count: int + failed_photo_count: int + error_message: Optional[str] + + +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, processing_status, + total_photo_count, batch_count, processed_photo_count, failed_photo_count, + created_at, approved_at, rejection_reason, error_message +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, processing_status, + total_photo_count, batch_count, processed_photo_count, failed_photo_count, + created_at, approved_at, rejection_reason, error_message +FROM upload_request_groups +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, processing_status, + total_photo_count, batch_count, processed_photo_count, failed_photo_count, + created_at, approved_at, rejection_reason, error_message +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, processing_status, + total_photo_count, batch_count, processed_photo_count, failed_photo_count, + created_at, approved_at, rejection_reason, error_message +FROM upload_request_groups +WHERE requested_by = :p1 + AND status = :p2 +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, processing_status, + total_photo_count, batch_count, processed_photo_count, failed_photo_count, + created_at, approved_at, rejection_reason, error_message +FROM upload_request_groups +WHERE status = :p1 +ORDER BY created_at DESC +""" + + +REJECT_UPLOAD_REQUEST_GROUP = f"""-- 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_COLUMNS} +""" + + +START_UPLOAD_REQUEST_GROUP_PROCESSING = f"""-- name: start_upload_request_group_processing \\:one +UPDATE upload_request_groups +SET processing_status = 'running', + error_message = NULL +WHERE id = :p1 + AND processing_status = 'pending' +{_RETURNING_COLUMNS} +""" + + +UPDATE_UPLOAD_REQUEST_GROUP_IMPORT_PROGRESS = f"""-- name: update_upload_request_group_import_progress \\:one +UPDATE upload_request_groups +SET total_photo_count = :p2, + batch_count = :p3, + processed_photo_count = :p4, + failed_photo_count = :p5 +WHERE id = :p1 +{_RETURNING_COLUMNS} +""" + + +@dataclasses.dataclass() +class UpdateUploadRequestGroupImportProgressParams: + id: uuid.UUID + total_photo_count: int + batch_count: int + processed_photo_count: int + failed_photo_count: int + + +def _to_model(row: sqlalchemy.engine.Row[Any]) -> 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], + processing_status=row[6], + total_photo_count=row[7], + batch_count=row[8], + processed_photo_count=row[9], + failed_photo_count=row[10], + created_at=row[11], + approved_at=row[12], + rejection_reason=row[13], + error_message=row[14], + ) + + +class AsyncQuerier: + def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): + self._conn = conn + + async def approve_upload_request_group( + self, + *, + id: uuid.UUID, + approved_by: Optional[uuid.UUID], + ) -> Optional[models.UploadRequestGroup]: + row = (await self._conn.execute( + sqlalchemy.text(APPROVE_UPLOAD_REQUEST_GROUP), + {"p1": id, "p2": approved_by}, + )).first() + return _to_model(row) if row is not None else None + + async def complete_upload_request_group_processing( + self, + arg: CompleteUploadRequestGroupProcessingParams, + ) -> Optional[models.UploadRequestGroup]: + row = (await self._conn.execute( + sqlalchemy.text(COMPLETE_UPLOAD_REQUEST_GROUP_PROCESSING), + { + "p1": arg.id, + "p2": arg.total_photo_count, + "p3": arg.batch_count, + "p4": arg.processed_photo_count, + "p5": arg.failed_photo_count, + }, + )).first() + return _to_model(row) if row is not None else None + + async def create_upload_request_group( + self, + arg: CreateUploadRequestGroupParams, + ) -> Optional[models.UploadRequestGroup]: + row = (await self._conn.execute( + sqlalchemy.text(CREATE_UPLOAD_REQUEST_GROUP), + { + "p1": arg.event_id, + "p2": arg.folder_id, + "p3": arg.requested_by, + "p4": arg.total_photo_count, + "p5": arg.batch_count, + }, + )).first() + return _to_model(row) if row is not None else None + + async def delete_upload_request_group(self, *, id: uuid.UUID) -> None: + await self._conn.execute(sqlalchemy.text(DELETE_UPLOAD_REQUEST_GROUP), {"p1": id}) + + async def fail_upload_request_group_processing( + self, + arg: FailUploadRequestGroupProcessingParams, + ) -> Optional[models.UploadRequestGroup]: + row = (await self._conn.execute( + sqlalchemy.text(FAIL_UPLOAD_REQUEST_GROUP_PROCESSING), + { + "p1": arg.id, + "p2": arg.total_photo_count, + "p3": arg.batch_count, + "p4": arg.processed_photo_count, + "p5": arg.failed_photo_count, + "p6": arg.error_message, + }, + )).first() + return _to_model(row) if row is not None else None + + 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() + return _to_model(row) if row is not None else None + + async def list_upload_request_groups(self) -> AsyncIterator[models.UploadRequestGroup]: + result = await self._conn.stream(sqlalchemy.text(LIST_UPLOAD_REQUEST_GROUPS)) + async for row in result: + yield _to_model(row) + + async def list_upload_request_groups_by_requester( + self, + *, + requested_by: uuid.UUID, + ) -> AsyncIterator[models.UploadRequestGroup]: + result = await self._conn.stream( + sqlalchemy.text(LIST_UPLOAD_REQUEST_GROUPS_BY_REQUESTER), + {"p1": requested_by}, + ) + async for row in result: + yield _to_model(row) + + async def list_upload_request_groups_by_requester_and_status( + self, + *, + requested_by: uuid.UUID, + status: Any, + ) -> AsyncIterator[models.UploadRequestGroup]: + result = await self._conn.stream( + sqlalchemy.text(LIST_UPLOAD_REQUEST_GROUPS_BY_REQUESTER_AND_STATUS), + {"p1": requested_by, "p2": status}, + ) + async for row in result: + yield _to_model(row) + + async def list_upload_request_groups_by_status( + self, + *, + status: Any, + ) -> AsyncIterator[models.UploadRequestGroup]: + result = await self._conn.stream( + sqlalchemy.text(LIST_UPLOAD_REQUEST_GROUPS_BY_STATUS), + {"p1": status}, + ) + async for row in result: + yield _to_model(row) + + async def reject_upload_request_group( + self, + *, + id: uuid.UUID, + approved_by: Optional[uuid.UUID], + rejection_reason: Optional[str], + ) -> Optional[models.UploadRequestGroup]: + row = (await self._conn.execute( + sqlalchemy.text(REJECT_UPLOAD_REQUEST_GROUP), + {"p1": id, "p2": approved_by, "p3": rejection_reason}, + )).first() + return _to_model(row) if row is not None else None + + async def start_upload_request_group_processing( + self, + *, + id: uuid.UUID, + ) -> Optional[models.UploadRequestGroup]: + row = (await self._conn.execute( + sqlalchemy.text(START_UPLOAD_REQUEST_GROUP_PROCESSING), + {"p1": id}, + )).first() + return _to_model(row) if row is not None else None + + async def update_upload_request_group_import_progress( + self, + arg: UpdateUploadRequestGroupImportProgressParams, + ) -> Optional[models.UploadRequestGroup]: + row = (await self._conn.execute( + sqlalchemy.text(UPDATE_UPLOAD_REQUEST_GROUP_IMPORT_PROGRESS), + { + "p1": arg.id, + "p2": arg.total_photo_count, + "p3": arg.batch_count, + "p4": arg.processed_photo_count, + "p5": arg.failed_photo_count, + }, + )).first() + return _to_model(row) if row is not None else None diff --git a/db/generated/upload_request_photos.py b/db/generated/upload_request_photos.py index 3dd7732..63eef9d 100644 --- a/db/generated/upload_request_photos.py +++ b/db/generated/upload_request_photos.py @@ -169,6 +169,10 @@ async def list_upload_request_photos_by_upload_request_i_ds(self, *, dollar_1: L created_at=row[12], ) + async def list_upload_request_photos_by_upload_request_ids(self, *, dollar_1: List[uuid.UUID]) -> AsyncIterator[models.UploadRequestPhoto]: + async for row in self.list_upload_request_photos_by_upload_request_i_ds(dollar_1=dollar_1): + yield row + async def list_upload_request_photos_by_upload_request_id(self, *, upload_request_id: uuid.UUID) -> AsyncIterator[models.UploadRequestPhoto]: result = await self._conn.stream(sqlalchemy.text(LIST_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_ID), {"p1": upload_request_id}) async for row in result: diff --git a/db/generated/upload_requests.py b/db/generated/upload_requests.py index 0008eca..db4887e 100644 --- a/db/generated/upload_requests.py +++ b/db/generated/upload_requests.py @@ -2,7 +2,8 @@ # versions: # sqlc v1.30.0 # source: upload_requests.sql -from typing import AsyncIterator, Optional +import dataclasses +from typing import Any, AsyncIterator, Optional import uuid import sqlalchemy @@ -19,35 +20,82 @@ rejection_reason = NULL WHERE id = :p1 AND status = 'pending' -RETURNING id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason +RETURNING id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason, group_id """ 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, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason +RETURNING id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason, group_id +""" + + +@dataclasses.dataclass() +class CreateUploadRequestParams: + event_id: uuid.UUID + group_id: Optional[uuid.UUID] + drive_file_id: Optional[str] + requested_by: uuid.UUID + photo_count: int + + +DELETE_UPLOAD_REQUEST = """-- name: delete_upload_request \\:exec +DELETE FROM upload_requests +WHERE id = :p1 """ GET_UPLOAD_REQUEST_BY_ID = """-- name: get_upload_request_by_id \\:one -SELECT id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason +SELECT id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason, group_id FROM upload_requests WHERE id = :p1 """ LIST_UPLOAD_REQUESTS = """-- name: list_upload_requests \\:many -SELECT id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason +SELECT id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason, group_id FROM upload_requests -WHERE requested_by = :p1\\:\\:uuid - AND status = COALESCE(:p2\\:\\:upload_request_status, status) +ORDER BY created_at DESC +""" + + +LIST_UPLOAD_REQUESTS_BY_GROUP_ID = """-- name: list_upload_requests_by_group_id \\:many +SELECT id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason, group_id +FROM upload_requests +WHERE group_id = :p1 +ORDER BY created_at ASC +""" + + +LIST_UPLOAD_REQUESTS_BY_REQUESTER = """-- name: list_upload_requests_by_requester \\:many +SELECT id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason, group_id +FROM upload_requests +WHERE requested_by = :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, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason, group_id +FROM upload_requests +WHERE requested_by = :p1 + AND status = :p2 +ORDER BY created_at DESC +""" + + +LIST_UPLOAD_REQUESTS_BY_STATUS = """-- name: list_upload_requests_by_status \\:many +SELECT id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason, group_id +FROM upload_requests +WHERE status = :p1 ORDER BY created_at DESC """ @@ -60,7 +108,7 @@ rejection_reason = :p3 WHERE id = :p1 AND status = 'pending' -RETURNING id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason +RETURNING id, event_id, drive_file_id, requested_by, approved_by, status, created_at, approved_at, photo_count, rejection_reason, group_id """ @@ -83,14 +131,16 @@ async def approve_upload_request(self, *, id: uuid.UUID, approved_by: Optional[u approved_at=row[7], photo_count=row[8], rejection_reason=row[9], + group_id=row[10], ) - async def create_upload_request(self, *, event_id: uuid.UUID, drive_file_id: Optional[str], requested_by: uuid.UUID, photo_count: int) -> Optional[models.UploadRequest]: + async def create_upload_request(self, arg: CreateUploadRequestParams) -> Optional[models.UploadRequest]: row = (await self._conn.execute(sqlalchemy.text(CREATE_UPLOAD_REQUEST), { - "p1": event_id, - "p2": drive_file_id, - "p3": requested_by, - "p4": photo_count, + "p1": arg.event_id, + "p2": arg.group_id, + "p3": arg.drive_file_id, + "p4": arg.requested_by, + "p5": arg.photo_count, })).first() if row is None: return None @@ -105,8 +155,12 @@ async def create_upload_request(self, *, event_id: uuid.UUID, drive_file_id: Opt approved_at=row[7], photo_count=row[8], rejection_reason=row[9], + group_id=row[10], ) + async def delete_upload_request(self, *, id: uuid.UUID) -> None: + await self._conn.execute(sqlalchemy.text(DELETE_UPLOAD_REQUEST), {"p1": id}) + async def get_upload_request_by_id(self, *, id: uuid.UUID) -> Optional[models.UploadRequest]: row = (await self._conn.execute(sqlalchemy.text(GET_UPLOAD_REQUEST_BY_ID), {"p1": id})).first() if row is None: @@ -122,10 +176,79 @@ async def get_upload_request_by_id(self, *, id: uuid.UUID) -> Optional[models.Up approved_at=row[7], photo_count=row[8], rejection_reason=row[9], + group_id=row[10], ) - async def list_upload_requests(self, *, dollar_1: uuid.UUID, p2: Optional[models.UploadRequestStatus]) -> AsyncIterator[models.UploadRequest]: - result = await self._conn.stream(sqlalchemy.text(LIST_UPLOAD_REQUESTS), {"p1": dollar_1, "p2": p2}) + async def list_upload_requests(self) -> AsyncIterator[models.UploadRequest]: + result = await self._conn.stream(sqlalchemy.text(LIST_UPLOAD_REQUESTS)) + async for row in result: + yield 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], + created_at=row[6], + approved_at=row[7], + photo_count=row[8], + rejection_reason=row[9], + group_id=row[10], + ) + + async def list_upload_requests_by_group_id(self, *, group_id: Optional[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 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], + created_at=row[6], + approved_at=row[7], + photo_count=row[8], + rejection_reason=row[9], + group_id=row[10], + ) + + async def list_upload_requests_by_requester(self, *, requested_by: uuid.UUID) -> AsyncIterator[models.UploadRequest]: + result = await self._conn.stream(sqlalchemy.text(LIST_UPLOAD_REQUESTS_BY_REQUESTER), {"p1": requested_by}) + async for row in result: + yield 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], + created_at=row[6], + approved_at=row[7], + photo_count=row[8], + rejection_reason=row[9], + group_id=row[10], + ) + + async def list_upload_requests_by_requester_and_status(self, *, requested_by: uuid.UUID, status: Any) -> AsyncIterator[models.UploadRequest]: + result = await self._conn.stream(sqlalchemy.text(LIST_UPLOAD_REQUESTS_BY_REQUESTER_AND_STATUS), {"p1": requested_by, "p2": status}) + async for row in result: + yield 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], + created_at=row[6], + approved_at=row[7], + photo_count=row[8], + rejection_reason=row[9], + group_id=row[10], + ) + + async def list_upload_requests_by_status(self, *, status: Any) -> AsyncIterator[models.UploadRequest]: + result = await self._conn.stream(sqlalchemy.text(LIST_UPLOAD_REQUESTS_BY_STATUS), {"p1": status}) async for row in result: yield models.UploadRequest( id=row[0], @@ -138,6 +261,7 @@ async def list_upload_requests(self, *, dollar_1: uuid.UUID, p2: Optional[models approved_at=row[7], photo_count=row[8], rejection_reason=row[9], + group_id=row[10], ) async def reject_upload_request(self, *, id: uuid.UUID, approved_by: Optional[uuid.UUID], rejection_reason: Optional[str]) -> Optional[models.UploadRequest]: @@ -155,4 +279,5 @@ async def reject_upload_request(self, *, id: uuid.UUID, approved_by: Optional[uu approved_at=row[7], photo_count=row[8], rejection_reason=row[9], + group_id=row[10], ) diff --git a/db/generated/user.py b/db/generated/user.py index 2599d3a..b236857 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -2,6 +2,7 @@ # versions: # sqlc v1.30.0 # source: user.sql +import dataclasses from typing import Any, AsyncIterator, Optional import uuid @@ -14,7 +15,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, deleted_at, blocked """ @@ -24,34 +25,84 @@ """ +FIND_CLOSEST_USER_BY_EMBEDDING = """-- name: find_closest_user_by_embedding \\:one +SELECT id, + (face_embedding <=> :p1\\:\\:vector) AS distance +FROM users +WHERE face_embedding IS NOT NULL +ORDER BY distance ASC +LIMIT 1 +""" + + +@dataclasses.dataclass() +class FindClosestUserByEmbeddingRow: + id: uuid.UUID + distance: Optional[Any] + + 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, deleted_at, blocked 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, deleted_at, blocked 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, deleted_at, blocked FROM users ORDER BY created_at DESC LIMIT :p1 OFFSET :p2 """ +LIST_USERS_WITH_EMBEDDING = """-- name: list_users_with_embedding \\:many +SELECT id, face_embedding +FROM users +WHERE face_embedding IS NOT NULL +AND deleted_at IS NULL +""" + + +@dataclasses.dataclass() +class ListUsersWithEmbeddingRow: + id: uuid.UUID + face_embedding: Optional[Any] + + +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, deleted_at, blocked +""" + + 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, deleted_at, blocked +""" + + +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, deleted_at, blocked """ @@ -60,7 +111,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, deleted_at, blocked """ @@ -81,11 +132,21 @@ async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Op display_name=row[5], face_embedding=row[6], deleted_at=row[7], + blocked=row[8], ) async def delete_user(self, *, id: uuid.UUID) -> None: await self._conn.execute(sqlalchemy.text(DELETE_USER), {"p1": id}) + async def find_closest_user_by_embedding(self, *, dollar_1: Any) -> Optional[FindClosestUserByEmbeddingRow]: + row = (await self._conn.execute(sqlalchemy.text(FIND_CLOSEST_USER_BY_EMBEDDING), {"p1": dollar_1})).first() + if row is None: + return None + return FindClosestUserByEmbeddingRow( + id=row[0], + distance=row[1], + ) + 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() if row is None: @@ -99,6 +160,7 @@ async def get_user_by_email(self, *, email: str) -> Optional[models.User]: display_name=row[5], face_embedding=row[6], deleted_at=row[7], + blocked=row[8], ) async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -114,6 +176,7 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: display_name=row[5], face_embedding=row[6], deleted_at=row[7], + blocked=row[8], ) async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: @@ -128,8 +191,33 @@ async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.U display_name=row[5], face_embedding=row[6], deleted_at=row[7], + blocked=row[8], + ) + + async def list_users_with_embedding(self) -> AsyncIterator[ListUsersWithEmbeddingRow]: + result = await self._conn.stream(sqlalchemy.text(LIST_USERS_WITH_EMBEDDING)) + async for row in result: + yield ListUsersWithEmbeddingRow( + id=row[0], + face_embedding=row[1], ) + 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], + deleted_at=row[7], + blocked=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: @@ -143,6 +231,28 @@ async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[ display_name=row[5], face_embedding=row[6], deleted_at=row[7], + blocked=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], + deleted_at=row[7], + blocked=row[8], ) async def update_user_password(self, *, hashed_password: Optional[str], id: uuid.UUID) -> Optional[models.User]: @@ -158,4 +268,5 @@ async def update_user_password(self, *, hashed_password: Optional[str], id: uuid display_name=row[5], face_embedding=row[6], deleted_at=row[7], + blocked=row[8], ) diff --git a/db/queries/photo_approvals.sql b/db/queries/photo_approvals.sql new file mode 100644 index 0000000..e8cb88b --- /dev/null +++ b/db/queries/photo_approvals.sql @@ -0,0 +1,25 @@ +-- name: CreatePhotoApproval :one +INSERT INTO photo_approvals ( + photo_id, + user_id, + decision +) VALUES ( + $1, $2, $3 +) +RETURNING *; + +-- name: UpdatePhotoApprovalDecision :one +UPDATE photo_approvals +SET decision = $2, decided_at = now() +WHERE photo_id = $1 AND user_id = $3 +RETURNING *; + +-- name: GetPhotoApprovalsByPhotoId :many +SELECT * FROM photo_approvals WHERE photo_id = $1; + +-- name: ListApprovalsByUserAndStatus :many +SELECT * FROM photo_approvals +WHERE user_id = $1 + AND ($2::varchar IS NULL OR decision = $2) +ORDER BY decided_at DESC +LIMIT $3 OFFSET $4; \ No newline at end of file diff --git a/db/queries/photo_faces.sql b/db/queries/photo_faces.sql new file mode 100644 index 0000000..a6286d9 --- /dev/null +++ b/db/queries/photo_faces.sql @@ -0,0 +1,109 @@ +-- 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 *; + +-- name: PhotoFacesPhotoExists :one +SELECT 1 +FROM photos +WHERE id = $1 +LIMIT 1; + +-- name: PhotoFacesMatchExistsForPhoto :one +SELECT 1 +FROM face_matches fm +JOIN photo_faces pf ON pf.id = fm.photo_face_id +WHERE pf.photo_id = $1 +LIMIT 1; + +-- name: PhotoFacesMatchExistsForPhotoFace :one +SELECT 1 +FROM face_matches +WHERE photo_face_id = $1 +LIMIT 1; + +-- name: PhotoFacesFindClosestUser :one +SELECT id, + (face_embedding <=> CAST($1 AS vector)) AS distance +FROM users +WHERE face_embedding IS NOT NULL +ORDER BY distance ASC +LIMIT 1; + +-- name: PhotoFacesEnsureFaceMatch :one +WITH upserted_photo_face AS ( + INSERT INTO photo_faces ( + photo_id, + face_index, + embedding, + bbox + ) VALUES ( + $1, + $2, + CAST($3 AS vector), + $4 + ) ON CONFLICT (photo_id, face_index) + DO UPDATE SET embedding = EXCLUDED.embedding, + bbox = EXCLUDED.bbox + RETURNING id, photo_id +), +existing_match AS ( + SELECT 1 + FROM face_matches fm + JOIN photo_faces pf ON pf.id = fm.photo_face_id + WHERE pf.photo_id = $1 + LIMIT 1 +), +inserted_match AS ( + INSERT INTO face_matches (photo_face_id, user_id, confidence) + SELECT upserted_photo_face.id, $5, $6 + WHERE NOT EXISTS (SELECT 1 FROM existing_match) + RETURNING id +) +SELECT upserted_photo_face.id AS photo_face_id, + inserted_match.id AS face_match_id +FROM upserted_photo_face +LEFT JOIN inserted_match ON TRUE; +-- name: UserHasFaceMatchForPhoto :one +SELECT 1 +FROM face_matches fm +JOIN photo_faces pf ON pf.id = fm.photo_face_id +WHERE pf.photo_id = $1 AND fm.user_id = $2 +LIMIT 1; + +-- name: InsertPhotoFaceWithApproval :one +WITH matched_user AS ( + SELECT id AS user_id + FROM users + WHERE face_embedding IS NOT NULL + AND deleted_at IS NULL + AND face_embedding <=> $3::vector <= $4 + ORDER BY face_embedding <=> $3::vector ASC + LIMIT 1 +), +insert_face AS ( + INSERT INTO photo_faces (photo_id, face_index, embedding, bbox) + VALUES ($1, $2, $3::vector, $5) + ON CONFLICT (photo_id, face_index) + DO UPDATE SET embedding = EXCLUDED.embedding, + bbox = EXCLUDED.bbox + RETURNING id, photo_id, face_index +), +matched AS ( + SELECT insert_face.photo_id, matched_user.user_id + FROM insert_face, matched_user + WHERE matched_user.user_id IS NOT NULL +) +INSERT INTO photo_approvals (photo_id, user_id, decision) +SELECT photo_id, user_id, $6 +FROM matched +RETURNING *; diff --git a/db/queries/photos.sql b/db/queries/photos.sql index 34e7c34..bd5ac56 100644 --- a/db/queries/photos.sql +++ b/db/queries/photos.sql @@ -9,3 +9,61 @@ INSERT INTO photos ( $1, $2, $3, $4, $5 ) RETURNING *; + +-- name: GetPhotoById :one +SELECT * FROM photos WHERE id = $1; + +-- name: UpdatePhotoStatus :one +UPDATE photos +SET status = $2 +WHERE id = $1 +RETURNING *; + +-- name: UpdatePhotoVisibility :one +UPDATE photos +SET visibility = $2 +WHERE id = $1 +RETURNING *; + +-- name: ListUserPhotos :many +SELECT DISTINCT p.* +FROM photos p +LEFT JOIN photo_faces pf ON pf.photo_id = p.id +LEFT JOIN face_matches fm ON fm.photo_face_id = pf.id AND fm.user_id = $1 +LEFT JOIN photo_approvals pa ON pa.photo_id = p.id AND pa.user_id = $1 +WHERE (fm.user_id = $1 OR pa.user_id = $1) + AND ($2::uuid IS NULL OR p.event_id = $2) +ORDER BY + CASE WHEN $3 = 'asc' THEN p.created_at END ASC, + CASE WHEN $3 != 'asc' THEN p.created_at END DESC +LIMIT $4 OFFSET $5; + +-- name: ListEventPhotosForUser :many +SELECT DISTINCT p.* +FROM photos p +LEFT JOIN photo_faces pf ON pf.photo_id = p.id +LEFT JOIN face_matches fm ON fm.photo_face_id = pf.id AND fm.user_id = $1 +LEFT JOIN photo_approvals pa ON pa.photo_id = p.id AND pa.user_id = $1 +WHERE p.event_id = $2 + AND p.status = 'approved' + AND (p.visibility = 'public' OR fm.user_id = $1 OR pa.user_id = $1) +ORDER BY + CASE WHEN $3 = 'asc' THEN p.created_at END ASC, + CASE WHEN $3 != 'asc' THEN p.created_at END DESC +LIMIT $4 OFFSET $5; + +-- name: CountEventPhotosForUser :one +SELECT COUNT(DISTINCT p.id) +FROM photos p +LEFT JOIN photo_faces pf ON pf.photo_id = p.id +LEFT JOIN face_matches fm ON fm.photo_face_id = pf.id AND fm.user_id = $1 +LEFT JOIN photo_approvals pa ON pa.photo_id = p.id AND pa.user_id = $1 +WHERE p.event_id = $2 + AND p.status = 'approved' + AND (p.visibility = 'public' OR fm.user_id = $1 OR pa.user_id = $1); + +-- name: GetDriveFileIdForPhoto :one +SELECT urp.drive_file_id +FROM upload_request_photos urp +WHERE urp.final_storage_key = $1 +LIMIT 1; diff --git a/db/queries/processing_jobs.sql b/db/queries/processing_jobs.sql new file mode 100644 index 0000000..dfc7d45 --- /dev/null +++ b/db/queries/processing_jobs.sql @@ -0,0 +1,18 @@ +-- name: CreateProcessingJob :one +INSERT INTO processing_jobs (photo_id, job_type, status) +VALUES ($1, $2, 'pending') +RETURNING *; + +-- name: UpdateProcessingJobStatus :one +UPDATE processing_jobs +SET status = $2, + attempts = attempts + 1, + completed_at = CASE WHEN $2 IN ('completed', 'failed') THEN now() ELSE completed_at END +WHERE id = $1 +RETURNING *; + +-- name: GetProcessingJobByPhotoId :one +SELECT * FROM processing_jobs +WHERE photo_id = $1 +ORDER BY created_at DESC +LIMIT 1; 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/staff_drive_connections.sql b/db/queries/staff_drive_connections.sql index fe12528..2a5d583 100644 --- a/db/queries/staff_drive_connections.sql +++ b/db/queries/staff_drive_connections.sql @@ -34,6 +34,13 @@ WHERE staff_user_id = $1 AND provider = $2 AND revoked_at IS NULL; +-- name: GetAnyActiveStaffDriveConnection :one +SELECT * +FROM staff_drive_connections +WHERE revoked_at IS NULL +ORDER BY connected_at DESC +LIMIT 1; + -- name: RevokeStaffDriveConnectionByStaffUserID :exec UPDATE staff_drive_connections SET revoked_at = NOW(), diff --git a/db/queries/upload_request_groups.sql b/db/queries/upload_request_groups.sql new file mode 100644 index 0000000..ea31144 --- /dev/null +++ b/db/queries/upload_request_groups.sql @@ -0,0 +1,103 @@ +-- 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: StartUploadRequestGroupProcessing :one +UPDATE upload_request_groups +SET processing_status = 'running', + error_message = NULL +WHERE id = $1 + AND processing_status = 'pending' +RETURNING *; + +-- name: UpdateUploadRequestGroupImportProgress :one +UPDATE upload_request_groups +SET total_photo_count = $2, + batch_count = $3, + processed_photo_count = $4, + failed_photo_count = $5 +WHERE id = $1 +RETURNING *; + +-- name: CompleteUploadRequestGroupProcessing :one +UPDATE upload_request_groups +SET processing_status = 'completed', + total_photo_count = $2, + batch_count = $3, + processed_photo_count = $4, + failed_photo_count = $5, + error_message = NULL +WHERE id = $1 +RETURNING *; + +-- name: FailUploadRequestGroupProcessing :one +UPDATE upload_request_groups +SET processing_status = 'failed', + total_photo_count = $2, + batch_count = $3, + processed_photo_count = $4, + failed_photo_count = $5, + error_message = $6 +WHERE id = $1 +RETURNING *; + +-- 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 c95fcad..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 requested_by = $1::uuid - AND status = COALESCE(sqlc.narg('p2')::upload_request_status, status) +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/db/queries/user.sql b/db/queries/user.sql index b9e984e..f203334 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; @@ -36,3 +52,16 @@ SET face_embedding = $1::vector, updated_at = NOW() WHERE id = $2 RETURNING *; + +-- name: FindClosestUserByEmbedding :one +SELECT id, + (face_embedding <=> $1::vector) AS distance +FROM users +WHERE face_embedding IS NOT NULL +ORDER BY distance ASC +LIMIT 1; +-- name: ListUsersWithEmbedding :many +SELECT id, face_embedding +FROM users +WHERE face_embedding IS NOT NULL +AND deleted_at IS NULL; diff --git a/makefile b/makefile index 3acf145..389088b 100644 --- a/makefile +++ b/makefile @@ -16,7 +16,7 @@ ifneq ("$(wildcard .env)","") export endif -.PHONY: migration-create m-up m-down gen get_db run-app lint +.PHONY: migration-create m-up m-down gen get_db run-app run-workers lint # Helper variable to call your new cleaning script CLEAN_SCHEMA = uv run python scripts/clean_schema.py db/schema.sql @@ -55,7 +55,16 @@ get_db: psql -U $(POSTGRES_USER) -d $(POSTGRES_DB) -h localhost -p $(POSTGRES_PORT) run-app: - uv run fastapi dev app/main.py + uv run uvicorn app.main:app --reload + +run-workers: + @trap 'kill 0; exit' INT TERM; \ + uv run python -m app.worker.audit.main & \ + uv run python -m app.worker.notification.main & \ + uv run python -m app.worker.upload_group_worker.main & \ + uv run python -m app.worker.photo_worker.main & \ + uv run python -m app.worker.storage_cleaner.main & \ + wait lint: uv run ruff check . 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/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/down/add_audit_photo_event_types.sql b/migrations/sql/down/add_audit_photo_event_types.sql new file mode 100644 index 0000000..60a040e --- /dev/null +++ b/migrations/sql/down/add_audit_photo_event_types.sql @@ -0,0 +1,4 @@ +-- PostgreSQL does not support removing enum values directly. +-- To fully reverse, you'd need to recreate the type. +-- This is a no-op for safety. +SELECT 1; diff --git a/migrations/sql/down/add_upload_group_processing_state.sql b/migrations/sql/down/add_upload_group_processing_state.sql new file mode 100644 index 0000000..44daa16 --- /dev/null +++ b/migrations/sql/down/add_upload_group_processing_state.sql @@ -0,0 +1,8 @@ +ALTER TABLE upload_request_groups + DROP CONSTRAINT IF EXISTS chk_upload_request_groups_processing_status; + +ALTER TABLE upload_request_groups + DROP COLUMN IF EXISTS error_message, + DROP COLUMN IF EXISTS failed_photo_count, + DROP COLUMN IF EXISTS processed_photo_count, + DROP COLUMN IF EXISTS processing_status; 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/add-audit-table.sql b/migrations/sql/up/add-audit-table.sql index 430f043..d162bb5 100644 --- a/migrations/sql/up/add-audit-table.sql +++ b/migrations/sql/up/add-audit-table.sql @@ -1,11 +1,15 @@ -CREATE TYPE IF NOT EXISTS public.audit_event_type AS ENUM ( - 'user.signup', - 'user.login', - 'user.logout', - 'upload_request.created', - 'upload_request.approved', - 'upload_request.rejected' -); +DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_event_type') THEN + CREATE TYPE public.audit_event_type AS ENUM ( + 'user.signup', + 'user.login', + 'user.logout', + 'upload_request.created', + 'upload_request.approved', + 'upload_request.rejected' + ); + END IF; +END $$; CREATE TABLE IF NOT EXISTS public.audit_events ( id uuid DEFAULT public.uuid_generate_v4() NOT NULL, 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/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/sql/up/add_audit_photo_event_types.sql b/migrations/sql/up/add_audit_photo_event_types.sql new file mode 100644 index 0000000..6fab458 --- /dev/null +++ b/migrations/sql/up/add_audit_photo_event_types.sql @@ -0,0 +1,2 @@ +ALTER TYPE audit_event_type ADD VALUE IF NOT EXISTS 'photo.processed'; +ALTER TYPE audit_event_type ADD VALUE IF NOT EXISTS 'photo_approval.decided'; diff --git a/migrations/sql/up/add_upload_group_processing_state.sql b/migrations/sql/up/add_upload_group_processing_state.sql new file mode 100644 index 0000000..adcd33b --- /dev/null +++ b/migrations/sql/up/add_upload_group_processing_state.sql @@ -0,0 +1,12 @@ +ALTER TABLE upload_request_groups + ADD COLUMN IF NOT EXISTS processing_status TEXT NOT NULL DEFAULT 'pending', + ADD COLUMN IF NOT EXISTS processed_photo_count INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS failed_photo_count INT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS error_message TEXT; + +ALTER TABLE upload_request_groups + DROP CONSTRAINT IF EXISTS chk_upload_request_groups_processing_status; + +ALTER TABLE upload_request_groups + ADD CONSTRAINT chk_upload_request_groups_processing_status + CHECK (processing_status IN ('pending', 'running', 'completed', 'failed')); 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/2d930ce4c68f_merge_all_heads.py b/migrations/versions/2d930ce4c68f_merge_all_heads.py new file mode 100644 index 0000000..19f9c4a --- /dev/null +++ b/migrations/versions/2d930ce4c68f_merge_all_heads.py @@ -0,0 +1,28 @@ +"""merge_all_heads + +Revision ID: 2d930ce4c68f +Revises: 4dd6658b9f83, 5b6615c9ab1d, a1f1d0b6e553, a7b4c2d1e9f0, d2c3e1f4a5b6 +Create Date: 2026-04-01 00:01:43.209988 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2d930ce4c68f' +down_revision: Union[str, Sequence[str], None] = ('4dd6658b9f83', '5b6615c9ab1d', 'a1f1d0b6e553', 'a7b4c2d1e9f0', 'd2c3e1f4a5b6') +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/4dd6658b9f83_merge_heads.py b/migrations/versions/4dd6658b9f83_merge_heads.py new file mode 100644 index 0000000..e7c5aea --- /dev/null +++ b/migrations/versions/4dd6658b9f83_merge_heads.py @@ -0,0 +1,26 @@ +"""merge heads + +Revision ID: 4dd6658b9f83 +Revises: 9f6c1b4a3d21, c3b8d0f1e2a4 +Create Date: 2026-03-21 23:29:09.967007 + +""" +from typing import Sequence, Union + + + +# 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/5b6615c9ab1d_merge_heads.py b/migrations/versions/5b6615c9ab1d_merge_heads.py new file mode 100644 index 0000000..0451545 --- /dev/null +++ b/migrations/versions/5b6615c9ab1d_merge_heads.py @@ -0,0 +1,26 @@ +"""merge_heads + +Revision ID: 5b6615c9ab1d +Revises: 9f1c3c6e9c1a, c3b8d0f1e2a4 +Create Date: 2026-03-20 02:33:56.591359 + +""" +from typing import Sequence, Union + + + +# 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") 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);") 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") diff --git a/migrations/versions/df24672cf9f3_add_audit_photo_event_types.py b/migrations/versions/df24672cf9f3_add_audit_photo_event_types.py new file mode 100644 index 0000000..338d760 --- /dev/null +++ b/migrations/versions/df24672cf9f3_add_audit_photo_event_types.py @@ -0,0 +1,25 @@ +"""add_audit_photo_event_types + +Revision ID: df24672cf9f3 +Revises: 2d930ce4c68f +Create Date: 2026-04-01 15:48:51.952542 + +""" +from typing import Sequence, Union + +from migrations.helper import run_sql_down, run_sql_up + + +# revision identifiers, used by Alembic. +revision: str = 'df24672cf9f3' +down_revision: Union[str, Sequence[str], None] = '2d930ce4c68f' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + run_sql_up("add_audit_photo_event_types") + + +def downgrade() -> None: + run_sql_down("add_audit_photo_event_types") diff --git a/migrations/versions/f3a1b7c9d201_add_upload_group_processing_state.py b/migrations/versions/f3a1b7c9d201_add_upload_group_processing_state.py new file mode 100644 index 0000000..7a4e954 --- /dev/null +++ b/migrations/versions/f3a1b7c9d201_add_upload_group_processing_state.py @@ -0,0 +1,25 @@ +"""add_upload_group_processing_state + +Revision ID: f3a1b7c9d201 +Revises: df24672cf9f3 +Create Date: 2026-04-01 20:40:00.000000 + +""" + +from typing import Sequence, Union + +from migrations.helper import run_sql_down, run_sql_up + + +revision: str = "f3a1b7c9d201" +down_revision: Union[str, Sequence[str], None] = "df24672cf9f3" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + run_sql_up("add_upload_group_processing_state") + + +def downgrade() -> None: + run_sql_down("add_upload_group_processing_state") diff --git a/pyproject.toml b/pyproject.toml index ca0bd4c..8646b74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,8 @@ dependencies = [ "onnxruntime>=1.24.4", "python-multipart>=0.0.22", "firebase-admin>=6.8.0", - "apns2>=0.7.1", "pywebpush>=2.3.0", + "opencv-python>=4.13.0.92", ] [tool.ruff] @@ -65,6 +65,11 @@ exclude = [ [dependency-groups] dev = [ "mypy>=1.19.1", + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", "ruff>=0.15.5", "types-passlib>=1.7.4", ] +[tool.pytest.ini_options] +pythonpath = ["."] +asyncio_mode = "auto" diff --git a/uv.lock b/uv.lock index f15e868..afaadba 100644 --- a/uv.lock +++ b/uv.lock @@ -203,20 +203,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] -[[package]] -name = "apns2" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "hyper" }, - { name = "pyjwt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a8/a1/0c66ac293963b132e7eeb8edf5fa035e96eae3262623305704c88df876e6/apns2-0.7.1.tar.gz", hash = "sha256:8c24207aa96dff4687f8d7c9149fc42086f3506b0a76da1f5bf48d74e5569567", size = 11052, upload-time = "2019-10-08T19:52:56.116Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/91/f27cd1299e00eb6955199856f19327de73c5eb803503356fd238b81d3430/apns2-0.7.1-py2.py3-none-any.whl", hash = "sha256:360bcd1f1d6308348adcb317c0192d1631fe01a2b9b73ce95f57c708de2bb88a", size = 9945, upload-time = "2019-10-08T19:52:54.313Z" }, -] - [[package]] name = "argon2-cffi" version = "23.1.0" @@ -315,7 +301,6 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, - { name = "apns2" }, { name = "asyncpg" }, { name = "bcrypt" }, { name = "cryptography" }, @@ -327,6 +312,7 @@ dependencies = [ { name = "nats-py" }, { name = "numpy" }, { name = "onnxruntime" }, + { name = "opencv-python" }, { name = "opencv-python-headless" }, { name = "passlib", extra = ["bcrypt"] }, { name = "psycopg" }, @@ -343,6 +329,8 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "ruff" }, { name = "types-passlib" }, ] @@ -350,7 +338,6 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.18.4" }, - { name = "apns2", specifier = ">=0.7.1" }, { name = "asyncpg", specifier = ">=0.31.0" }, { name = "bcrypt", specifier = "==4.3.0" }, { name = "cryptography", specifier = ">=46.0.5" }, @@ -362,6 +349,7 @@ requires-dist = [ { name = "nats-py", specifier = ">=2.14.0" }, { name = "numpy", specifier = ">=2.4.3" }, { name = "onnxruntime", specifier = ">=1.24.4" }, + { name = "opencv-python", specifier = ">=4.13.0.92" }, { name = "opencv-python-headless", specifier = ">=4.13.0.92" }, { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7.4" }, { name = "psycopg", specifier = ">=3.3.3" }, @@ -378,6 +366,8 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.19.1" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.3.0" }, { name = "ruff", specifier = ">=0.15.5" }, { name = "types-passlib", specifier = ">=1.7.4" }, ] @@ -1349,28 +1339,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] -[[package]] -name = "h2" -version = "2.6.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "hpack" }, - { name = "hyperframe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/ad/73a6c1a40eadbf9eef93fe16285a366c834cbd61783c30e6c23ef4b11e53/h2-2.6.2.tar.gz", hash = "sha256:af35878673c83a44afbc12b13ac91a489da2819b5dc1e11768f3c2406f740fe9", size = 169942, upload-time = "2017-04-03T07:56:34.319Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8b/8d5610e8ddbcde6d014907526b4c6c294520a7233fc456d7be1fcade3bbc/h2-2.6.2-py2.py3-none-any.whl", hash = "sha256:93cbd1013a2218539af05cdf9fc37b786655b93bbc94f5296b7dabd1c5cadf41", size = 71894, upload-time = "2017-04-03T07:56:30.674Z" }, -] - -[[package]] -name = "hpack" -version = "3.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/44/f1/b4440e46e265a29c0cb7b09b6daec6edf93c79eae713cfed93fbbf8716c5/hpack-3.0.0.tar.gz", hash = "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2", size = 43321, upload-time = "2017-03-29T13:00:11.691Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/cc/e53517f4a1e13f74776ca93271caef378dadec14d71c61c949d759d3db69/hpack-3.0.0-py2.py3-none-any.whl", hash = "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89", size = 38552, upload-time = "2017-03-29T13:00:09.659Z" }, -] - [[package]] name = "http-ece" version = "1.2.1" @@ -1449,28 +1417,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] -[[package]] -name = "hyper" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "h2" }, - { name = "hyperframe" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/af/f7/f60d8032f331994f29ce2d79fb5d7fe1e3c1355cac0078c070cf4feb3b52/hyper-0.7.0.tar.gz", hash = "sha256:12c82eacd122a659673484c1ea0d34576430afbe5aa6b8f63fe37fcb06a2458c", size = 631878, upload-time = "2016-09-27T12:58:46.21Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/96/c3/e77072050a8d3a22255695d0cd7fde19bfe962364a6f6870ef47a9f9f66b/hyper-0.7.0-py2.py3-none-any.whl", hash = "sha256:069514f54231fb7b5df2fb910a114663a83306d5296f588fffcb0a9be19407fc", size = 269790, upload-time = "2016-09-27T12:58:42.841Z" }, -] - -[[package]] -name = "hyperframe" -version = "3.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/96/7080c938d2b06105365bae946c77c78a32d9e763eaa05d0e431b02d7bc12/hyperframe-3.2.0.tar.gz", hash = "sha256:05f0e063e117c16fcdd13c12c93a4424a2c40668abfac3bb419a10f57698204e", size = 16177, upload-time = "2016-02-02T14:45:41.109Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/89/44ff46f15dba53a8c16cb8cab89ecb1e44f8aa211628b43d341004cfcf7a/hyperframe-3.2.0-py2.py3-none-any.whl", hash = "sha256:4dcab11967482d400853b396d042038e4c492a15a5d2f57259e2b5f89a32f755", size = 13636, upload-time = "2016-02-02T14:45:48.989Z" }, -] - [[package]] name = "idna" version = "3.11" @@ -1493,6 +1439,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/fa/391e437a34e55095173dca5f24070d89cbc233ff85bf1c29c93248c6588d/imageio-2.37.3-py3-none-any.whl", hash = "sha256:46f5bb8522cd421c0f5ae104d8268f569d856b29eb1a13b92829d1970f32c9f0", size = 317646, upload-time = "2026-03-09T11:31:10.771Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "insightface" version = "0.7.3" @@ -2226,6 +2181,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" }, ] +[[package]] +name = "opencv-python" +version = "4.13.0.92" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" }, + { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" }, + { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" }, + { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" }, + { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, +] + [[package]] name = "opencv-python-headless" version = "4.13.0.92" @@ -2345,6 +2318,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "prettytable" version = "3.17.0" @@ -2712,6 +2694,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"