diff --git a/Dockerfile b/Dockerfile index 076b031..b2361fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ FROM python:3.12-slim # Install PostgreSQL client libraries and build tools RUN apt-get update \ && apt-get install -y --no-install-recommends libpq-dev build-essential \ - libglib2.0-0 libgfortran5 \ + libglib2.0-0 libgfortran5 libgl1 libxext6 libsm6 libxrender1 libxcb1 \ && rm -rf /var/lib/apt/lists/* ENV PYTHONDONTWRITEBYTECODE=1 diff --git a/README.md b/README.md index 37e3fd8..98999e9 100644 --- a/README.md +++ b/README.md @@ -1 +1,11 @@ -s7a 3idkom \ No newline at end of file +# multAI Backend + +Project run instructions are documented here: + +- [Run Modes Guide](docs/RUN_MODES.md) + +Use the guide to choose the right workflow: + +- Backend Dev Mode (hot reload on host) +- Staging Mode (shared image-based containers) +- Backend Staging-Check Mode (local code in staging-like containers) \ No newline at end of file diff --git a/app/router/mobile/photo_approval.py b/app/router/mobile/photo_approval.py index 321f9f4..3aead0d 100644 --- a/app/router/mobile/photo_approval.py +++ b/app/router/mobile/photo_approval.py @@ -18,19 +18,20 @@ async def list_my_approvals( current_user: MobileUserSchema = Depends(get_current_mobile_user), container: Container = Depends(get_container), ) -> list[dict[str, object]]: - approvals = [] + approvals: list[dict[str, object]] = [] 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, + status=status, ): - approvals.append({ + item: dict[str, object] = { "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, - }) + } + approvals.append(item) return approvals diff --git a/app/service/device.py b/app/service/device.py index 18c2928..4bc4879 100644 --- a/app/service/device.py +++ b/app/service/device.py @@ -86,7 +86,7 @@ async def inactivate_device( user_id: uuid.UUID, ) -> None: try: - device = await self.device_querier.get_device__by_id(id=device_id) + device = await self.device_querier.get_device_by_id(id=device_id) if device is None or device.user_id != user_id: raise AppException.not_found("Device not found") await self.device_querier.deactivate_device( @@ -112,7 +112,7 @@ async def get_device_by_id( user_id: uuid.UUID, ) -> UserDevice: try : - device = await self.device_querier.get_device__by_id(id=device_id) + device = await self.device_querier.get_device_by_id(id=device_id) if device is None : raise AppException.not_found("device not found ") return device @@ -121,7 +121,7 @@ async def get_device_by_id( async def count_devices(self: "DeviceService", user_id: uuid.UUID) -> int: try : - count = await self.device_querier.count__user__devices(user_id=user_id) + count = await self.device_querier.count_user_devices(user_id=user_id) if count is None : raise AppException.internal_error("db failed to count ") return count diff --git a/app/service/face_match.py b/app/service/face_match.py index eb1745f..672c17e 100644 --- a/app/service/face_match.py +++ b/app/service/face_match.py @@ -59,19 +59,9 @@ async def process_detected_face( 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") + if await self._autoapprove_if_unmatchable(job, matched_user): return + assert matched_user is not None params = photo_face_queries.PhotoFacesEnsureFaceMatchParams( photo_id=job.photo_id, @@ -103,6 +93,7 @@ async def process_detected_face( return if created_face_match_id: + assert matched_user is not None await self.photo_querier.update_photo_status( id=job.photo_id, status="approved", ) @@ -114,6 +105,27 @@ async def process_detected_face( }, ) + async def _autoapprove_if_unmatchable( + self, + job: SingleFaceMatchJob, + matched_user: ClosestUserMatch | None, + ) -> bool: + 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 True + + 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 True + + return False + 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 diff --git a/app/service/users.py b/app/service/users.py index 649df54..fd16d71 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -49,7 +49,7 @@ async def _ensure_device_for_login( user_id: uuid.UUID, req: MobileAuthRequest, ) -> UserDevice: - existing_device = await self.device_querier.get_device__by_id(id=req.device_id) + existing_device = await self.device_querier.get_device_by_id(id=req.device_id) if existing_device: if existing_device.user_id != user_id: diff --git a/app/worker/photo_worker/main.py b/app/worker/photo_worker/main.py index 85e70c2..651b606 100644 --- a/app/worker/photo_worker/main.py +++ b/app/worker/photo_worker/main.py @@ -22,6 +22,7 @@ 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 models 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 @@ -35,7 +36,7 @@ class PhotoApprovalDecision(str, Enum): class PhotoWorker: - + def __init__( self, conn: AsyncConnection, @@ -94,7 +95,6 @@ async def handle_message(self, data: bytes) -> None: 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 @@ -119,7 +119,6 @@ async def _handle_single_face(self, event: PhotoProcessEvent, face: DetectedFace 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)) @@ -180,7 +179,7 @@ async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[Detect ) - async def _create_job(self, event: PhotoProcessEvent) -> object | None: + async def _create_job(self, event: PhotoProcessEvent) -> models.ProcessingJob | None: if self._pj_querier is None: return None try: @@ -191,11 +190,11 @@ async def _create_job(self, event: PhotoProcessEvent) -> object | None: 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: + async def _update_job(self, job: models.ProcessingJob | 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] + await self._pj_querier.update_processing_job_status(id=job.id, status=status) except Exception as exc: logger.warning("Failed to update processing job: %s", exc) diff --git a/db/generated/devices.py b/db/generated/devices.py index fb2e064..e90ebdd 100644 --- a/db/generated/devices.py +++ b/db/generated/devices.py @@ -20,7 +20,7 @@ """ -COUNT__USER__DEVICES = """-- name: count__user__devices \\:one +COUNT_USER_DEVICES = """-- name: count_user_devices \\:one SELECT COUNT(*) FROM user_devices WHERE user_id = :p1 @@ -67,7 +67,7 @@ class CreateDeviceParams: """ -GET_DEVICE__BY_ID = """-- name: get_device__by_id \\:one +GET_DEVICE_BY_ID = """-- name: get_device_by_id \\:one 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 """ @@ -123,8 +123,8 @@ def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): async def activate_device(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None: await self._conn.execute(sqlalchemy.text(ACTIVATE_DEVICE), {"p1": id, "p2": user_id}) - async def count__user__devices(self, *, user_id: uuid.UUID) -> Optional[int]: - row = (await self._conn.execute(sqlalchemy.text(COUNT__USER__DEVICES), {"p1": user_id})).first() + async def count_user_devices(self, *, user_id: uuid.UUID) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(COUNT_USER_DEVICES), {"p1": user_id})).first() if row is None: return None return row[0] @@ -159,8 +159,8 @@ async def deactivate_device(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None: async def enable_device2_fa(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None: await self._conn.execute(sqlalchemy.text(ENABLE_DEVICE2_FA), {"p1": id, "p2": user_id}) - async def get_device__by_id(self, *, id: uuid.UUID) -> Optional[models.UserDevice]: - row = (await self._conn.execute(sqlalchemy.text(GET_DEVICE__BY_ID), {"p1": id})).first() + async def get_device_by_id(self, *, id: uuid.UUID) -> Optional[models.UserDevice]: + row = (await self._conn.execute(sqlalchemy.text(GET_DEVICE_BY_ID), {"p1": id})).first() if row is None: return None return models.UserDevice( diff --git a/db/generated/models.py b/db/generated/models.py index 37d2f68..7943214 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -15,6 +15,8 @@ class AuditEventType(str, enum.Enum): UPLOAD_REQUESTCREATED = "upload_request.created" UPLOAD_REQUESTAPPROVED = "upload_request.approved" UPLOAD_REQUESTREJECTED = "upload_request.rejected" + PHOTOPROCESSED = "photo.processed" + PHOTO_APPROVALDECIDED = "photo_approval.decided" class EventStatus(str, enum.Enum): @@ -204,14 +206,14 @@ class UploadRequestGroup: 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] + processing_status: str + processed_photo_count: int + failed_photo_count: int error_message: Optional[str] diff --git a/db/generated/photo_approvals.py b/db/generated/photo_approvals.py index 3ef8a75..f712392 100644 --- a/db/generated/photo_approvals.py +++ b/db/generated/photo_approvals.py @@ -31,9 +31,9 @@ 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) + AND (:p4\\:\\:varchar IS NULL OR decision = :p4) ORDER BY decided_at DESC -LIMIT :p3 OFFSET :p4 +LIMIT :p2 OFFSET :p3 """ @@ -72,12 +72,12 @@ async def get_photo_approvals_by_photo_id(self, *, photo_id: uuid.UUID) -> Async 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]: + async def list_approvals_by_user_and_status(self, *, user_id: uuid.UUID, limit: int, offset: int, status: Optional[str]) -> 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, + "p2": limit, + "p3": offset, + "p4": status, }) async for row in result: yield models.PhotoApproval( diff --git a/db/generated/upload_request_groups.py b/db/generated/upload_request_groups.py index 617e34f..dacf93d 100644 --- a/db/generated/upload_request_groups.py +++ b/db/generated/upload_request_groups.py @@ -12,14 +12,7 @@ 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 +APPROVE_UPLOAD_REQUEST_GROUP = """-- name: approve_upload_request_group \\:one UPDATE upload_request_groups SET status = 'approved', approved_by = :p2, @@ -27,11 +20,11 @@ rejection_reason = NULL WHERE id = :p1 AND status = 'pending' -{_RETURNING_COLUMNS} +RETURNING id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, error_message """ -COMPLETE_UPLOAD_REQUEST_GROUP_PROCESSING = f"""-- name: complete_upload_request_group_processing \\:one +COMPLETE_UPLOAD_REQUEST_GROUP_PROCESSING = """-- name: complete_upload_request_group_processing \\:one UPDATE upload_request_groups SET processing_status = 'completed', total_photo_count = :p2, @@ -40,7 +33,7 @@ failed_photo_count = :p5, error_message = NULL WHERE id = :p1 -{_RETURNING_COLUMNS} +RETURNING id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, error_message """ @@ -53,7 +46,7 @@ class CompleteUploadRequestGroupProcessingParams: failed_photo_count: int -CREATE_UPLOAD_REQUEST_GROUP = f"""-- name: create_upload_request_group \\:one +CREATE_UPLOAD_REQUEST_GROUP = """-- name: create_upload_request_group \\:one INSERT INTO upload_request_groups ( event_id, folder_id, @@ -63,7 +56,7 @@ class CompleteUploadRequestGroupProcessingParams: ) VALUES ( :p1, :p2, :p3, :p4, :p5 ) -{_RETURNING_COLUMNS} +RETURNING id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, error_message """ @@ -82,7 +75,7 @@ class CreateUploadRequestGroupParams: """ -FAIL_UPLOAD_REQUEST_GROUP_PROCESSING = f"""-- name: fail_upload_request_group_processing \\:one +FAIL_UPLOAD_REQUEST_GROUP_PROCESSING = """-- name: fail_upload_request_group_processing \\:one UPDATE upload_request_groups SET processing_status = 'failed', total_photo_count = :p2, @@ -91,7 +84,7 @@ class CreateUploadRequestGroupParams: failed_photo_count = :p5, error_message = :p6 WHERE id = :p1 -{_RETURNING_COLUMNS} +RETURNING id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, error_message """ @@ -106,27 +99,21 @@ class FailUploadRequestGroupProcessingParams: 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 +SELECT id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, 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 +SELECT id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, 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 +SELECT id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, error_message FROM upload_request_groups WHERE requested_by = :p1 ORDER BY created_at DESC @@ -134,9 +121,7 @@ class FailUploadRequestGroupProcessingParams: 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 +SELECT id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, error_message FROM upload_request_groups WHERE requested_by = :p1 AND status = :p2 @@ -145,16 +130,14 @@ class FailUploadRequestGroupProcessingParams: 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 +SELECT id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, 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 +REJECT_UPLOAD_REQUEST_GROUP = """-- name: reject_upload_request_group \\:one UPDATE upload_request_groups SET status = 'rejected', approved_by = :p2, @@ -162,28 +145,28 @@ class FailUploadRequestGroupProcessingParams: rejection_reason = :p3 WHERE id = :p1 AND status = 'pending' -{_RETURNING_COLUMNS} +RETURNING id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, error_message """ -START_UPLOAD_REQUEST_GROUP_PROCESSING = f"""-- name: start_upload_request_group_processing \\:one +START_UPLOAD_REQUEST_GROUP_PROCESSING = """-- 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} +RETURNING id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, error_message """ -UPDATE_UPLOAD_REQUEST_GROUP_IMPORT_PROGRESS = f"""-- name: update_upload_request_group_import_progress \\:one +UPDATE_UPLOAD_REQUEST_GROUP_IMPORT_PROGRESS = """-- 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} +RETURNING id, event_id, folder_id, requested_by, approved_by, status, total_photo_count, batch_count, created_at, approved_at, rejection_reason, processing_status, processed_photo_count, failed_photo_count, error_message """ @@ -196,183 +179,294 @@ class UpdateUploadRequestGroupImportProgressParams: 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 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() + if row is None: + return None + return models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) + + 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() + if row is None: + return None + return models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) + + 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() + if row is None: + return None + return models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) 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 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() + if row is None: + return None + return models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) + + async def get_upload_request_group_by_id(self, *, id: uuid.UUID) -> Optional[models.UploadRequestGroup]: + row = (await self._conn.execute(sqlalchemy.text(GET_UPLOAD_REQUEST_GROUP_BY_ID), {"p1": id})).first() + if row is None: + return None + return models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) 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}, - ) + yield models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) + + 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}, - ) + yield models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) + + 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}, - ) + yield models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) + + 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 + yield models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) + + 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() + if row is None: + return None + return models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) + + 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() + if row is None: + return None + return models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) + + 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() + if row is None: + return None + return models.UploadRequestGroup( + id=row[0], + event_id=row[1], + folder_id=row[2], + requested_by=row[3], + approved_by=row[4], + status=row[5], + total_photo_count=row[6], + batch_count=row[7], + created_at=row[8], + approved_at=row[9], + rejection_reason=row[10], + processing_status=row[11], + processed_photo_count=row[12], + failed_photo_count=row[13], + error_message=row[14], + ) diff --git a/db/generated/upload_request_photos.py b/db/generated/upload_request_photos.py index 63eef9d..2180eab 100644 --- a/db/generated/upload_request_photos.py +++ b/db/generated/upload_request_photos.py @@ -59,18 +59,18 @@ class CreateUploadRequestPhotoParams: """ -LIST_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_I_DS = """-- name: list_upload_request_photos_by_upload_request_i_ds \\:many +LIST_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_ID = """-- name: list_upload_request_photos_by_upload_request_id \\:many SELECT id, upload_request_id, drive_file_id, file_name, mime_type, size_bytes, staging_storage_key, final_storage_key, taken_at, day_number, visibility, status, created_at FROM upload_request_photos -WHERE upload_request_id = ANY(:p1\\:\\:uuid[]) +WHERE upload_request_id = :p1 ORDER BY created_at ASC """ -LIST_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_ID = """-- name: list_upload_request_photos_by_upload_request_id \\:many +LIST_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_IDS = """-- name: list_upload_request_photos_by_upload_request_ids \\:many SELECT id, upload_request_id, drive_file_id, file_name, mime_type, size_bytes, staging_storage_key, final_storage_key, taken_at, day_number, visibility, status, created_at FROM upload_request_photos -WHERE upload_request_id = :p1 +WHERE upload_request_id = ANY(:p1\\:\\:uuid[]) ORDER BY created_at ASC """ @@ -150,8 +150,8 @@ async def get_upload_request_photo_by_id(self, *, id: uuid.UUID) -> Optional[mod created_at=row[12], ) - async def list_upload_request_photos_by_upload_request_i_ds(self, *, dollar_1: List[uuid.UUID]) -> AsyncIterator[models.UploadRequestPhoto]: - result = await self._conn.stream(sqlalchemy.text(LIST_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_I_DS), {"p1": dollar_1}) + 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: yield models.UploadRequestPhoto( id=row[0], @@ -170,11 +170,7 @@ async def list_upload_request_photos_by_upload_request_i_ds(self, *, dollar_1: L ) 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}) + result = await self._conn.stream(sqlalchemy.text(LIST_UPLOAD_REQUEST_PHOTOS_BY_UPLOAD_REQUEST_IDS), {"p1": dollar_1}) async for row in result: yield models.UploadRequestPhoto( id=row[0], diff --git a/db/queries/devices.sql b/db/queries/devices.sql index 2b512d8..aa651c7 100644 --- a/db/queries/devices.sql +++ b/db/queries/devices.sql @@ -34,11 +34,11 @@ WHERE id = $1 AND user_id = $2 AND is_2fa_enabled = FALSE; --- name: Get_device_By_id :one +-- name: GetDeviceById :one SELECT * from user_devices WHERE id =$1; --- name: Count_User_Devices :one +-- name: CountUserDevices :one SELECT COUNT(*) FROM user_devices WHERE user_id = $1; diff --git a/db/queries/notifications.sql b/db/queries/notifications.sql index b7dc9ec..d14f8ce 100644 --- a/db/queries/notifications.sql +++ b/db/queries/notifications.sql @@ -8,7 +8,7 @@ INSERT INTO notifications ( ) RETURNING id, user_id, type, payload, read_at, created_at; --- name: ListNotificationsByUserID :many +-- name: ListNotificationsByUserId :many SELECT id, user_id, type, payload, read_at, created_at FROM notifications WHERE user_id = $1 diff --git a/db/queries/photo_approvals.sql b/db/queries/photo_approvals.sql index e8cb88b..ac6b787 100644 --- a/db/queries/photo_approvals.sql +++ b/db/queries/photo_approvals.sql @@ -20,6 +20,6 @@ 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) + AND (sqlc.narg('status')::varchar IS NULL OR decision = sqlc.narg('status')) ORDER BY decided_at DESC -LIMIT $3 OFFSET $4; \ No newline at end of file +LIMIT $2 OFFSET $3; diff --git a/db/queries/session.sql b/db/queries/session.sql index b22911e..cc574ee 100644 --- a/db/queries/session.sql +++ b/db/queries/session.sql @@ -23,7 +23,7 @@ SELECT * FROM user_sessions WHERE device_id = $1; --- name: GetSessionByID :one +-- name: GetSessionById :one SELECT * FROM user_sessions WHERE id = $1; diff --git a/db/queries/staff_drive_connections.sql b/db/queries/staff_drive_connections.sql index 2a5d583..88e91b1 100644 --- a/db/queries/staff_drive_connections.sql +++ b/db/queries/staff_drive_connections.sql @@ -27,7 +27,7 @@ DO UPDATE SET updated_at = NOW() RETURNING *; --- name: GetActiveStaffDriveConnectionByStaffUserID :one +-- name: GetActiveStaffDriveConnectionByStaffUserId :one SELECT * FROM staff_drive_connections WHERE staff_user_id = $1 @@ -41,7 +41,7 @@ WHERE revoked_at IS NULL ORDER BY connected_at DESC LIMIT 1; --- name: RevokeStaffDriveConnectionByStaffUserID :exec +-- name: RevokeStaffDriveConnectionByStaffUserId :exec UPDATE staff_drive_connections SET revoked_at = NOW(), updated_at = NOW() diff --git a/db/queries/staff_notifications.sql b/db/queries/staff_notifications.sql index 74070b8..ea85a72 100644 --- a/db/queries/staff_notifications.sql +++ b/db/queries/staff_notifications.sql @@ -8,7 +8,7 @@ INSERT INTO staff_notifications ( ) RETURNING *; --- name: ListStaffNotificationsByStaffUserID :many +-- name: ListStaffNotificationsByStaffUserId :many SELECT * FROM staff_notifications WHERE staff_user_id = $1 diff --git a/db/queries/stuff_user.sql b/db/queries/stuff_user.sql index 777109d..aaae438 100644 --- a/db/queries/stuff_user.sql +++ b/db/queries/stuff_user.sql @@ -8,7 +8,7 @@ INSERT INTO staff_users (email, password, role) VALUES ($1, $2, $3) RETURNING *; --- name: GetStaffUserByID :one +-- name: GetStaffUserById :one SELECT * FROM staff_users WHERE id = $1; diff --git a/db/queries/upload_request_groups.sql b/db/queries/upload_request_groups.sql index ea31144..7c800f1 100644 --- a/db/queries/upload_request_groups.sql +++ b/db/queries/upload_request_groups.sql @@ -10,7 +10,7 @@ INSERT INTO upload_request_groups ( ) RETURNING *; --- name: GetUploadRequestGroupByID :one +-- name: GetUploadRequestGroupById :one SELECT * FROM upload_request_groups WHERE id = $1; diff --git a/db/queries/upload_request_photos.sql b/db/queries/upload_request_photos.sql index bab46b5..f78ab85 100644 --- a/db/queries/upload_request_photos.sql +++ b/db/queries/upload_request_photos.sql @@ -15,19 +15,19 @@ INSERT INTO upload_request_photos ( ) RETURNING *; --- name: ListUploadRequestPhotosByUploadRequestID :many +-- name: ListUploadRequestPhotosByUploadRequestId :many SELECT * FROM upload_request_photos WHERE upload_request_id = $1 ORDER BY created_at ASC; --- name: ListUploadRequestPhotosByUploadRequestIDs :many +-- name: ListUploadRequestPhotosByUploadRequestIds :many SELECT * FROM upload_request_photos WHERE upload_request_id = ANY($1::uuid[]) ORDER BY created_at ASC; --- name: GetUploadRequestPhotoByID :one +-- name: GetUploadRequestPhotoById :one SELECT * FROM upload_request_photos WHERE id = $1; @@ -39,12 +39,12 @@ SET status = $2, WHERE id = $1 RETURNING *; --- name: UpdateUploadRequestPhotoStatusByUploadRequestID :many +-- name: UpdateUploadRequestPhotoStatusByUploadRequestId :many UPDATE upload_request_photos SET status = $2 WHERE upload_request_id = $1 RETURNING *; --- name: DeleteUploadRequestPhotosByUploadRequestID :exec +-- name: DeleteUploadRequestPhotosByUploadRequestId :exec DELETE FROM upload_request_photos WHERE upload_request_id = $1; diff --git a/db/queries/upload_requests.sql b/db/queries/upload_requests.sql index 043f641..31eb373 100644 --- a/db/queries/upload_requests.sql +++ b/db/queries/upload_requests.sql @@ -10,12 +10,12 @@ INSERT INTO upload_requests ( ) RETURNING *; --- name: GetUploadRequestByID :one +-- name: GetUploadRequestById :one SELECT * FROM upload_requests WHERE id = $1; --- name: ListUploadRequestsByGroupID :many +-- name: ListUploadRequestsByGroupId :many SELECT * FROM upload_requests WHERE group_id = $1 diff --git a/db/queries/user.sql b/db/queries/user.sql index f203334..a46577b 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -3,7 +3,7 @@ INSERT INTO users (email, hashed_password) VALUES ($1, $2) RETURNING *; --- name: GetUserByID :one +-- name: GetUserById :one SELECT * FROM users WHERE id = $1; diff --git a/docker-compose.staging.local.yml b/docker-compose.staging.local.yml new file mode 100644 index 0000000..135eb75 --- /dev/null +++ b/docker-compose.staging.local.yml @@ -0,0 +1,18 @@ +services: + fastapi: + build: + context: . + dockerfile: Dockerfile + pull_policy: never + volumes: + - ./:/app + - insightface_cache:/root/.insightface + + migrate: + build: + context: . + dockerfile: Dockerfile + pull_policy: never + +volumes: + insightface_cache: diff --git a/docs/RUN_MODES.md b/docs/RUN_MODES.md new file mode 100644 index 0000000..09d84c6 --- /dev/null +++ b/docs/RUN_MODES.md @@ -0,0 +1,102 @@ +# Run Modes Guide + +There are three ways to run the backend. Pick the one that matches what you are trying to do. + +## 1) Backend Dev Mode (Hot Reload) + +Use this for normal backend development. + +What runs: + +- Infrastructure containers only: Postgres, Redis, NATS, MinIO, pgAdmin +- FastAPI runs on your host machine with reload (`uvicorn --reload`) + +Commands: + +```powershell +docker compose -f docker-compose.yml up -d +make run-app +``` + +Optional workers: + +```powershell +make run-workers +``` + +Best for: + +- Fast local iteration +- Debugging backend code with auto-reload + +--- + +## 2) Staging Mode (Frontend / Shared Image-Based) + +Use this when the frontend team (or anyone else) needs a stable backend running from the published image. + +What runs: + +- Infrastructure containers +- `fastapi` and `migrate` from `ghcr.io/microclub-usthb/multai-back:latest` + +Commands: + +```powershell +docker compose -f docker-compose.staging.yml up -d +``` + +Check status and logs: + +```powershell +docker compose -f docker-compose.staging.yml ps +docker compose -f docker-compose.staging.yml logs -f fastapi +``` + +Health check: + +```powershell +curl -I http://localhost:8000/docs +``` + +Note: + +- The first startup can take a while because model files are downloaded during app initialization. + +--- + +## 3) Backend Staging-Check Mode (Current Local Code in Containers) + +Use this before pushing or opening a PR to verify your current local code works in the same containerized setup as staging. + +What runs: + +- Base staging services from `docker-compose.staging.yml` +- Local overrides from `docker-compose.staging.local.yml`: + - build from the local `Dockerfile` + - `pull_policy: never` + - source code mounted into `/app` + +Commands (recommended via Makefile): + +```powershell +make staging-check-up +make staging-check-logs +make staging-check-down +``` + +Equivalent raw compose commands: + +```powershell +docker compose -f docker-compose.staging.yml -f docker-compose.staging.local.yml up --build -d +docker compose -f docker-compose.staging.yml -f docker-compose.staging.local.yml logs -f fastapi +docker compose -f docker-compose.staging.yml -f docker-compose.staging.local.yml down +``` + +Best for: + +- Validating migrations and API startup in containers +- Catching container/runtime issues before sharing changes + +--- + diff --git a/makefile b/makefile index 389088b..343f4c0 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 run-workers lint +.PHONY: migration-create m-up m-down gen get_db run-app run-workers lint staging-check-up staging-check-logs staging-check-down # Helper variable to call your new cleaning script CLEAN_SCHEMA = uv run python scripts/clean_schema.py db/schema.sql @@ -71,3 +71,12 @@ lint: check_type: uv run mypy . + +staging-check-up: + docker compose -f docker-compose.staging.yml -f docker-compose.staging.local.yml up --build -d + +staging-check-logs: + docker compose -f docker-compose.staging.yml -f docker-compose.staging.local.yml logs -f fastapi + +staging-check-down: + docker compose -f docker-compose.staging.yml -f docker-compose.staging.local.yml down