diff --git a/.env.example b/.env.example index 0143d34..bb6f6b2 100644 --- a/.env.example +++ b/.env.example @@ -49,4 +49,8 @@ FACE_ENCRYPTION_KEY=hkbribvfirirbvivbibvib CORS_ORIGINS=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"] # Firebase -FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json \ No newline at end of file +FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json + +# Resend Email Configuration +RESEND_API_KEY= +EMAIL_FROM= \ No newline at end of file diff --git a/app/container.py b/app/container.py index 4d08b1f..5311498 100644 --- a/app/container.py +++ b/app/container.py @@ -37,7 +37,10 @@ from db.generated import stuff_user as staff_queries from db.generated import notifications as notification_queries from db.generated import audit as audit_queries +from db.generated import stats as stats_queries + from app.service.event import EventService +from app.service.stats import StatsService from app.worker.notification.notification_queue import NotificationQueue from app.worker.notification.settings import NotifSetting @@ -70,7 +73,7 @@ def __init__( self.event_querier = event_queries.AsyncQuerier(conn) self.participant_querier = participant_queries.AsyncQuerier(conn) self.staff_querier = staff_queries.AsyncQuerier(conn) - + self.stats_querier = stats_queries.AsyncQuerier(conn) # services self.session_service = SessionService() @@ -150,6 +153,10 @@ def __init__( staff_drive_service=self.staff_drive_service, ) + self.stats_service = StatsService( + querier=self.stats_querier, + ) + 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 ce42498..b04e3ef 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -33,6 +33,8 @@ class Settings(BaseSettings): POSTGRES_HOST: str = "localhost" POSTGRES_PORT: int = 5432 + PHOTO_APPROVAL_TIMEOUT_DAYS: int = 7 + # Mobile auth/session defaults MOBILE_SESSION_LIMIT: int = 3 MOBILE_SESSION_TTL_SECONDS: int = 180 @@ -57,8 +59,8 @@ class Settings(BaseSettings): # Face embedding model FACE_EMBEDDING_MODEL_NAME: str = "buffalo_l" - FACE_EMBEDDING_PROVIDERS: str = "CPUExecutionProvider" - FACE_EMBEDDING_CTX_ID: int = -1 + FACE_EMBEDDING_PROVIDERS: str = "CUDAExecutionProvider,CPUExecutionProvider" + FACE_EMBEDDING_CTX_ID: int = 0 FACE_EMBEDDING_DET_WIDTH: int = 640 FACE_EMBEDDING_DET_HEIGHT: int = 640 @@ -73,6 +75,10 @@ class Settings(BaseSettings): FACE_ENCRYPTION_KEY: str FIREBASE_CREDENTIALS_PATH: str + # Resend Email Configuration + RESEND_API_KEY: str = "" + EMAIL_FROM: str = "onboarding@resend.dev" + model_config = SettingsConfigDict( env_file=".env", extra="ignore", diff --git a/app/core/constant.py b/app/core/constant.py index b1ffd28..847c119 100644 --- a/app/core/constant.py +++ b/app/core/constant.py @@ -22,6 +22,7 @@ class AuditEventType(str, Enum): USER_SIGNUP = "user.signup" USER_LOGIN = "user.login" USER_LOGOUT = "user.logout" + FACE_ENROLLMENT_ATTEMPT = "face_enrollment.attempt" UPLOAD_REQUEST_CREATED = "upload_request.created" UPLOAD_REQUEST_APPROVED = "upload_request.approved" UPLOAD_REQUEST_REJECTED = "upload_request.rejected" @@ -50,5 +51,11 @@ class AuditEventType(str, Enum): GOOGLE_DRIVE_FILES_URL = "https://www.googleapis.com/drive/v3/files/{file_id}" MAX_IMAGE_SIZE = 5 * 1024 * 1024 +MIN_IMAGE_DIM = 64 +MAX_IMAGE_DIM = 4096 MIN_ENROLL_IMAGES = 3 MAX_ENROLL_IMAGES = 5 + +ENROLL_RATE_LIMIT_MAX = 5 +ENROLL_RATE_LIMIT_WINDOW = 3600 +ENROLL_IN_PROGRESS_TTL_SECONDS = 300 diff --git a/app/deps/cookie_auth.py b/app/deps/cookie_auth.py index 749870b..ae9b388 100644 --- a/app/deps/cookie_auth.py +++ b/app/deps/cookie_auth.py @@ -50,3 +50,14 @@ async def require_multi_team_lead_staff( current_staff_user: Annotated[StaffUser, Depends(get_current_staff_user)], ) -> StaffUser: return ensure_multi_team_lead_staff(current_staff_user) + + +def ensure_admin_staff(current_staff_user: StaffUser) -> StaffUser: + if _role_value(current_staff_user.role) != StaffRole.ADMIN.value: + raise AppException.forbidden("Admin access required") + return current_staff_user + +async def require_admin_staff( + current_staff_user: Annotated[StaffUser, Depends(get_current_staff_user)], +) -> StaffUser: + return ensure_admin_staff(current_staff_user) diff --git a/app/deps/rate_limit.py b/app/deps/rate_limit.py new file mode 100644 index 0000000..a21e205 --- /dev/null +++ b/app/deps/rate_limit.py @@ -0,0 +1,36 @@ +from fastapi import Request, HTTPException +from typing import Callable + +from app.infra.redis import RedisClient +from app.core.config import settings + +def _get_client_ip(request: Request) -> str: + if settings.TRUST_PROXY_HEADERS: + forwarded_for = request.headers.get("x-forwarded-for") + if forwarded_for: + return forwarded_for.split(",", maxsplit=1)[0].strip() + real_ip = request.headers.get("x-real-ip") + if real_ip: + return real_ip.strip() + return request.client.host if request.client else "127.0.0.1" + + +def RateLimiter(requests: int, window: int) -> Callable: + async def _rate_limit_dependency(request: Request) -> None: + client_ip = _get_client_ip(request) + # For simplicity, IP based rate limit on the endpoint + path = request.url.path + key = f"rate_limit:{path}:{client_ip}" + + redis = RedisClient.get_instance() + + # Increment request count + current = await redis.incr(key) + if current == 1: + # Set expiry for the window if it's the first request + await redis.expire(key, window) + + if current > requests: + raise HTTPException(status_code=429, detail="Too Many Requests") + + return _rate_limit_dependency diff --git a/app/infra/email.py b/app/infra/email.py new file mode 100644 index 0000000..b2beaf5 --- /dev/null +++ b/app/infra/email.py @@ -0,0 +1,61 @@ +import json +import urllib.request +import urllib.error +import asyncio +from app.core.config import settings +from app.core.logger import logger + +class EmailSender: + @staticmethod + async def send_otp_email(to_email: str, otp: str) -> bool: + if not settings.RESEND_API_KEY: + logger.warning("RESEND_API_KEY is not set. Skipping email sending.") + # During development without an API key, just log the OTP + logger.info("MOCK EMAIL to %s: Your OTP is %s", to_email, otp) + return True + + url = "https://api.resend.com/emails" + headers = { + "Authorization": f"Bearer {settings.RESEND_API_KEY}", + "Content-Type": "application/json", + "User-Agent": "multAI-Backend/1.0" + } + + html_content = f""" +
+

Bienvenue sur multAI !

+

Voici votre code de vérification :

+

{otp}

+

Ce code est valide pendant 10 minutes.

+
+ """ + + data = { + "from": settings.EMAIL_FROM, + "to": [to_email], + "subject": "Votre code de vérification multAI", + "html": html_content + } + + req = urllib.request.Request( + url, + data=json.dumps(data).encode("utf-8"), + headers=headers, + method="POST" + ) + + def _send() -> bool: + try: + with urllib.request.urlopen(req, timeout=10) as response: + res_body = response.read() + logger.info("Email sent via Resend. Response: %s", res_body) + return True + except urllib.error.HTTPError as e: + err_body = e.read() + logger.error("Failed to send email via Resend: %s - %s", e.code, err_body) + return False + except Exception as e: + logger.error("Error sending email: %s", str(e)) + return False + + return await asyncio.to_thread(_send) diff --git a/app/infra/redis.py b/app/infra/redis.py index acfaa28..d3116be 100644 --- a/app/infra/redis.py +++ b/app/infra/redis.py @@ -59,20 +59,19 @@ async def expire(self, key: RedisKey | str, seconds: int) -> bool: return int(cast(int, result)) == 1 async def incr(self, key: RedisKey | str) -> int: - result = await self._client.incr(key) - return int(cast(int, result)) + return await self._client.incr(key) async def sadd(self, key: RedisKey | str, *values: str) -> int: - result = self._client.sadd(key, *values) + result = await self._client.sadd(key, *values) # type: ignore[misc] return int(cast(int, result)) async def sismember(self, key: RedisKey | str, value: str) -> bool: - result = self._client.sismember(key, value) + result = await self._client.sismember(key, value) # type: ignore[misc] return int(cast(int, result)) == 1 async def srem(self, key: RedisKey | str, *values: str) -> int: - result = self._client.srem(key, *values) + result = await self._client.srem(key, *values) # type: ignore[misc] return int(cast(int, result)) diff --git a/app/main.py b/app/main.py index 1e06dcb..34d64d3 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint from app.core.config import settings +from app.infra.database import engine from app.infra.minio import init_minio_client from app.infra.nats import NatsClient from app.infra.redis import RedisClient @@ -48,6 +49,18 @@ async def dispatch( +async def _approval_expiry_loop() -> None: + while True: + await asyncio.sleep(3600) + try: + async with engine.begin() as conn: + from app.container import Container + container = Container(conn) + await container.photo_approval_service.expire_stale(settings.PHOTO_APPROVAL_TIMEOUT_DAYS) + except Exception as exc: + logger.warning("Approval expiry task failed: %s", exc) + + MAX_RETRIES = 5 RETRY_DELAY = 2 # seconds @asynccontextmanager @@ -77,8 +90,12 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: await NatsClient.connect() get_face_embedding_service() + expiry_task = asyncio.create_task(_approval_expiry_loop()) + yield + expiry_task.cancel() + await asyncio.gather(expiry_task, return_exceptions=True) await RedisClient.get_instance().close() await NatsClient.close() diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 8a9a1a0..01f6c4a 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -7,15 +7,18 @@ from app.core.config import settings from app.core.constant import AuditEventType from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.deps.rate_limit import RateLimiter from app.schema.request.mobile.auth import ( MobileLoginRequest, MobileRegisterRequest, + RegisterVerifyRequest, + ResendOtpRequest, RefreshTokenRequest, UpdateDeviceTokenRequest, InactivateDeviceRequest, ) -from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema +from app.schema.response.mobile.auth import MeResponse, DeviceSchema, MobileAuthResponse, SessionSchema, UserSchema, RegisterPendingResponse router = APIRouter(prefix="/auth") @@ -33,23 +36,45 @@ def _get_client_ip(request: Request) -> str | None: return request.client.host if request.client else None -@router.post("/register", response_model=MobileAuthResponse) +@router.post("/register", response_model=RegisterPendingResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))]) async def mobile_register( req: MobileRegisterRequest, request: Request, container: Container = Depends(get_container), -) -> MobileAuthResponse: +) -> RegisterPendingResponse: client_ip = _get_client_ip(request) result = await container.auth_service.mobile_register(container.redis, req, client_ip=client_ip) + return result + + +@router.post("/register/resend-otp", response_model=RegisterPendingResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))]) +async def mobile_register_resend_otp( + req: ResendOtpRequest, + request: Request, + container: Container = Depends(get_container), +) -> RegisterPendingResponse: + client_ip = _get_client_ip(request) + result = await container.auth_service.mobile_register_resend_otp(container.redis, req.email, client_ip=client_ip) + return result + + +@router.post("/register/verify", response_model=MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=10, window=60))]) +async def mobile_register_verify( + req: RegisterVerifyRequest, + request: Request, + container: Container = Depends(get_container), +) -> MobileAuthResponse: + client_ip = _get_client_ip(request) + result = await container.auth_service.verify_mobile_register(container.redis, req, client_ip=client_ip) await container.audit_service.create_record( event_type=AuditEventType.USER_SIGNUP, user_id=result.user_id, - metadata={"endpoint": "register"}, + metadata={"endpoint": "register_verify"}, ) return result -@router.post("/login", response_model=MobileAuthResponse) +@router.post("/login", response_model=MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))]) async def mobile_login( req: MobileLoginRequest, request: Request, diff --git a/app/router/mobile/enrollement.py b/app/router/mobile/enrollement.py index 1a5f652..6ebb13d 100644 --- a/app/router/mobile/enrollement.py +++ b/app/router/mobile/enrollement.py @@ -1,75 +1,296 @@ +import re +import time +import uuid +from collections.abc import AsyncIterator +from io import BytesIO from typing import Annotated, List -from fastapi import APIRouter, File, UploadFile, Depends +import filetype # type: ignore[import-untyped] +import pillow_heif # type: ignore[import-untyped] +from fastapi import APIRouter, Depends, File, HTTPException, UploadFile +from fastapi.concurrency import run_in_threadpool +from PIL import Image +from pydantic import BaseModel 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 ( - DEFAULT_CONTENT_TYPE, + ENROLL_IN_PROGRESS_TTL_SECONDS, + AuditEventType, + ENROLL_RATE_LIMIT_MAX, + ENROLL_RATE_LIMIT_WINDOW, IMAGE_ALLOWED_TYPES, MAX_ENROLL_IMAGES, MAX_IMAGE_SIZE, + MAX_IMAGE_DIM, MIN_ENROLL_IMAGES, + MIN_IMAGE_DIM, ) +from app.core.exceptions import AppException +from app.core.logger import logger +from app.deps.token_auth import MobileUserSchema, get_current_mobile_user from app.service.face_embedding import FaceImagePayload -from db.generated.models import User + + +pillow_heif.register_heif_opener() + + +Image.MAX_IMAGE_PIXELS = MAX_IMAGE_DIM * MAX_IMAGE_DIM + + +class EnrollmentResponse(BaseModel): + id: uuid.UUID + + model_config = {"from_attributes": True} + router = APIRouter() -@router.post("/enroll") +def _sanitise_filename(raw: str | None, extension: str) -> str: + prefix = str(uuid.uuid4()) + if not raw: + return f"{prefix}.{extension}" + name = re.sub(r'[\\/:*?"<>|\x00-\x1f]', "_", raw) + name = name.lstrip(".")[:128] + return f"{prefix}_{name}" + + +def _validate_dimensions(contents: bytes) -> None: + + try: + img = Image.open(BytesIO(contents)) + w, h = img.size + except Exception as e: + raise AppException.image_format_error( + "File could not be decoded as a valid image" + ) from e + + max_pixels = Image.MAX_IMAGE_PIXELS + + if max_pixels is not None and w * h > max_pixels: + raise AppException.bad_request( + f"Image exceeds maximum allowed resolution of {max_pixels} total pixels." + ) + + try: + img.load() + except Exception as e: + raise AppException.image_format_error( + "File contains corrupted or incomplete pixel data" + ) from e + + if w < MIN_IMAGE_DIM or h < MIN_IMAGE_DIM: + raise AppException.bad_request( + f"Image too small — minimum {MIN_IMAGE_DIM}x{MIN_IMAGE_DIM} px" + ) + if w > MAX_IMAGE_DIM or h > MAX_IMAGE_DIM: + raise AppException.bad_request( + f"Image too large — maximum {MAX_IMAGE_DIM}x{MAX_IMAGE_DIM} px" + ) + + +async def read_limited(file: UploadFile, limit: int) -> bytes: + chunks: list[bytes] = [] + total = 0 + while True: + chunk = await file.read(65536) + if not chunk: + break + total += len(chunk) + if total > limit: + raise AppException.bad_request( + f"File exceeds maximum allowed size of {limit} bytes" + ) + chunks.append(chunk) + + await file.seek(0) + return b"".join(chunks) + + +def _precheck_upload_headers(file: UploadFile) -> None: + content_type = file.content_type + if not content_type: + raise AppException.image_format_error("Missing image Content-Type header") + + normalized_content_type = content_type.split(";", maxsplit=1)[0].strip().lower() + if normalized_content_type not in IMAGE_ALLOWED_TYPES: + allowed = ", ".join(IMAGE_ALLOWED_TYPES) + raise AppException.image_format_error( + f"Unsupported Content-Type header. Allowed types: {allowed}" + ) + + content_length = file.headers.get("content-length") + if content_length is None: + return + + try: + declared_size = int(content_length) + except ValueError as exc: + raise AppException.bad_request("Invalid image Content-Length header") from exc + + if declared_size > MAX_IMAGE_SIZE: + raise AppException.bad_request( + f"File exceeds maximum allowed size of {MAX_IMAGE_SIZE} bytes" + ) + + +async def _build_face_image_payload(file: UploadFile) -> FaceImagePayload: + _precheck_upload_headers(file) + contents = await read_limited(file, MAX_IMAGE_SIZE) + + kind = filetype.guess(contents) + if kind is None or kind.mime not in IMAGE_ALLOWED_TYPES: + raise AppException.image_format_error( + f"Unsupported format. Allowed types: {', '.join(IMAGE_ALLOWED_TYPES)}" + ) + + await run_in_threadpool(_validate_dimensions, contents) + + return FaceImagePayload( + filename=_sanitise_filename(file.filename, kind.extension), + content_type=kind.mime, + bytes=contents, + ) + + +async def _record_enrollment_audit( + *, + container: Container, + user_id: uuid.UUID, + image_count: int, + outcome: str, + duration_ms: int, + error_category: str | None = None, +) -> None: + metadata: dict[str, object] = { + "endpoint": "enroll", + "image_count": image_count, + "outcome": outcome, + "duration_ms": duration_ms, + } + if error_category is not None: + metadata["error_category"] = error_category + + try: + await container.audit_service.create_record( + event_type=AuditEventType.FACE_ENROLLMENT_ATTEMPT, + user_id=user_id, + metadata=metadata, + ) + except Exception as exc: + logger.warning( + "Failed to publish enrollment audit for user %s: %s", user_id, exc + ) + + +def _enrollment_lock_key(user_id: uuid.UUID) -> str: + return f"enroll:in_progress:{user_id}" + + +async def _release_enrollment_lock( + *, + container: Container, + lock_key: str, + lock_value: str, +) -> None: + try: + if await container.redis.get(lock_key) == lock_value: + await container.redis.delete(lock_key) + except Exception as exc: + logger.warning("Failed to release enrollment lock %s: %s", lock_key, exc) + + +@router.post("/enroll", response_model=EnrollmentResponse) async def enroll_face( - files: Annotated[ + files: Annotated[ List[UploadFile], File( - description="Upload one or more face images", - openapi_examples={ - "single_file": { - "summary": "One file example", - "description": "Example of uploading one file", - "value": "example.jpg" - }, - "multiple_files": { - "summary": "Multiple files example", - "description": "Example of uploading multiple files", - "value": ["face1.png", "face2.png"] - }, - }, + description=( + f"Between {MIN_ENROLL_IMAGES} and {MAX_ENROLL_IMAGES} face images " + f"(JPEG, PNG, HEIC, or HEIF). " + f"Each file must be under {MAX_IMAGE_SIZE // (1024 * 1024)} MB " + f"and at least {MIN_IMAGE_DIM}x{MIN_IMAGE_DIM} px." + ), ), ], container: Container = Depends(get_container), user: MobileUserSchema = Depends(get_current_mobile_user), -) -> User: - - if not (MIN_ENROLL_IMAGES <= len(files) <= MAX_ENROLL_IMAGES): - raise AppException.bad_request( - f"You must upload between {MIN_ENROLL_IMAGES} and {MAX_ENROLL_IMAGES} images for enrollment." - ) +) -> EnrollmentResponse: + start_time = time.perf_counter() + image_count = len(files) + lock_key: str | None = None + lock_value: str | None = None + lock_acquired = False + async def image_payloads() -> AsyncIterator[FaceImagePayload]: + for file in files: + yield await _build_face_image_payload(file) - image_payloads: list[FaceImagePayload] = [] - for file in files: - if file.content_type not in IMAGE_ALLOWED_TYPES: - raise AppException.image_format_error( - f"File {file.filename} has unsupported format {file.content_type}" - ) + try: + await container.auth_service.check_rate_limit( + redis=container.redis, + key=f"rate:enroll:{user.user_id}", + max_requests=ENROLL_RATE_LIMIT_MAX, + window_seconds=ENROLL_RATE_LIMIT_WINDOW, + ) - contents = await file.read() - if len(contents) > MAX_IMAGE_SIZE: + if not (MIN_ENROLL_IMAGES <= image_count <= MAX_ENROLL_IMAGES): raise AppException.bad_request( - f"File {file.filename} exceeds maximum size of {MAX_IMAGE_SIZE} bytes" + f"You must upload between {MIN_ENROLL_IMAGES} and " + f"{MAX_ENROLL_IMAGES} images for enrollment." ) - payload: FaceImagePayload = FaceImagePayload( - filename=file.filename or "unknown", - content_type=file.content_type or DEFAULT_CONTENT_TYPE, - bytes=contents, + lock_key = _enrollment_lock_key(user.user_id) + lock_value = str(uuid.uuid4()) + lock_acquired = await container.redis.set( + lock_key, + lock_value, + expire=ENROLL_IN_PROGRESS_TTL_SECONDS, + nx=True, ) + if not lock_acquired: + raise AppException.conflict( + "Enrollment already in progress. Please wait for it to finish." + ) - image_payloads.append(payload) - - return await container.auth_service.add_embbed_user( - user.user_id, - image_payloads, - ) + updated_user = await container.auth_service.add_embbed_user( + user.user_id, + image_payloads(), + ) + await _record_enrollment_audit( + container=container, + user_id=user.user_id, + image_count=image_count, + outcome="success", + duration_ms=int((time.perf_counter() - start_time) * 1000), + ) + return EnrollmentResponse.model_validate(updated_user) + except HTTPException as exc: + await _record_enrollment_audit( + container=container, + user_id=user.user_id, + image_count=image_count, + outcome="failure", + duration_ms=int((time.perf_counter() - start_time) * 1000), + error_category=f"http_{exc.status_code}", + ) + raise + except Exception as e: + await _record_enrollment_audit( + container=container, + user_id=user.user_id, + image_count=image_count, + outcome="failure", + duration_ms=int((time.perf_counter() - start_time) * 1000), + error_category="unexpected_error", + ) + raise AppException.internal_error( + "Enrollment failed due to an internal error" + ) from e + finally: + if lock_acquired and lock_key is not None and lock_value is not None: + await _release_enrollment_lock( + container=container, + lock_key=lock_key, + lock_value=lock_value, + ) diff --git a/app/router/mobile/photos.py b/app/router/mobile/photos.py index 01bdfe6..00113f9 100644 --- a/app/router/mobile/photos.py +++ b/app/router/mobile/photos.py @@ -6,11 +6,12 @@ from app.container import Container, get_container from app.deps.token_auth import MobileUserSchema, get_current_mobile_user +from app.deps.rate_limit import RateLimiter router = APIRouter(prefix="/photos") -@router.get("") +@router.get("", dependencies=[Depends(RateLimiter(requests=20, window=60))]) async def list_my_photos( event_id: UUID | None = Query(default=None), sort: Literal["asc", "desc"] = Query(default="desc"), diff --git a/app/router/web/__init__.py b/app/router/web/__init__.py index b7939c3..1e5390a 100644 --- a/app/router/web/__init__.py +++ b/app/router/web/__init__.py @@ -4,6 +4,7 @@ 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 +from app.router.web.stats import router as stats_router router = APIRouter(prefix="/admin", tags=["admin"]) router.include_router(staff_users_router) @@ -11,3 +12,4 @@ router.include_router(auth_routes) router.include_router(audit_router) router.include_router(users_router) +router.include_router(stats_router) diff --git a/app/router/web/auth.py b/app/router/web/auth.py index 0eecad7..7629cf9 100644 --- a/app/router/web/auth.py +++ b/app/router/web/auth.py @@ -3,6 +3,7 @@ from fastapi import Response from app.deps.cookie_auth import get_current_staff_user +from app.deps.rate_limit import RateLimiter from app.schema.request.web.auth import WebAuthRequest from app.schema.response.web.auth import WebAuthResponse from app.schema.response.web.staff_user import StaffUserSchema @@ -10,7 +11,7 @@ router = APIRouter(prefix="/auth") -@router.post("/login", response_model=WebAuthResponse,description="so here both the dahbsoard will authneticate from this endpoitn ") +@router.post("/login", response_model=WebAuthResponse, description="so here both the dahbsoard will authneticate from this endpoitn ", dependencies=[Depends(RateLimiter(requests=5, window=60))]) async def admin_login( req: WebAuthRequest, r:Response, diff --git a/app/router/web/stats.py b/app/router/web/stats.py new file mode 100644 index 0000000..3d9bb46 --- /dev/null +++ b/app/router/web/stats.py @@ -0,0 +1,45 @@ +from fastapi import APIRouter, Depends +from app.container import Container, get_container +from app.deps.cookie_auth import require_admin_staff +from db.generated.models import StaffUser +from app.schema.response.web.stats import ( + AdminStatsResponse, DriveUsageResponse, + ProcessingLoadResponse, AlertResponse +) + +router = APIRouter(prefix="/stats", tags=["Web - Stats"]) + +@router.get("/dashboard", response_model=AdminStatsResponse) +async def get_dashboard( + container: Container = Depends(get_container), + current_admin: StaffUser = Depends(require_admin_staff) +) -> AdminStatsResponse: + """Staff Admin Only: Get global KPIs for the dashboard""" + return await container.stats_service.get_dashboard_stats() + + +@router.get("/processing-load", response_model=ProcessingLoadResponse) +async def get_processing_load( + container: Container = Depends(get_container), + current_admin: StaffUser = Depends(require_admin_staff) +) -> ProcessingLoadResponse: + """Staff Admin Only: Get pipeline processing load percentages""" + return await container.stats_service.get_processing_load() + + +@router.get("/storage", response_model=DriveUsageResponse) +async def get_storage( + container: Container = Depends(get_container), + current_admin: StaffUser = Depends(require_admin_staff) +) -> DriveUsageResponse: + """Staff Admin Only: Get MinIO storage consumption""" + return await container.stats_service.get_storage_usage() + + +@router.get("/alerts", response_model=AlertResponse) +async def get_alerts( + container: Container = Depends(get_container), + current_admin: StaffUser = Depends(require_admin_staff) +) -> AlertResponse: + """Staff Admin Only: Get recent alerts/notifications for the admin""" + return await container.stats_service.get_staff_alerts(current_admin.id) diff --git a/app/schema/request/mobile/auth.py b/app/schema/request/mobile/auth.py index b272f2b..23a1371 100644 --- a/app/schema/request/mobile/auth.py +++ b/app/schema/request/mobile/auth.py @@ -63,9 +63,24 @@ class MobileLoginRequest(MobileAuthBaseRequest): pass +class RegisterVerifyRequest(MobileAuthBaseRequest): + otp: str = Field(..., min_length=6, max_length=6, description="The 6-digit OTP code sent via email") + + +class ResendOtpRequest(BaseModel): + email: EmailStr = Field(..., max_length=255) + + @field_validator("email", mode="before") + @classmethod + def _normalize_email(cls, value: object) -> object: + if not isinstance(value, str): + return value + return value.strip().lower() + + class RefreshTokenRequest(BaseModel): refresh_token: str diff --git a/app/schema/response/mobile/auth.py b/app/schema/response/mobile/auth.py index 154f9a4..e03a074 100644 --- a/app/schema/response/mobile/auth.py +++ b/app/schema/response/mobile/auth.py @@ -25,6 +25,10 @@ class MeResponse(BaseModel): sessions: Optional[SessionSchema] +class RegisterPendingResponse(BaseModel): + message: str + status: str + email: str class MobileAuthResponse(BaseModel): access_token: str diff --git a/app/schema/response/web/stats.py b/app/schema/response/web/stats.py new file mode 100644 index 0000000..f3bcdbc --- /dev/null +++ b/app/schema/response/web/stats.py @@ -0,0 +1,35 @@ +from pydantic import BaseModel +from datetime import datetime +from typing import List, Optional + +class AdminStatsResponse(BaseModel): + active_events: int + photos_uploaded: int + processed_photos: int + queue_size: int + timestamp: datetime + +class DriveUsageResponse(BaseModel): + used_bytes: int + total_bytes: int + timestamp: datetime + +class AlertItem(BaseModel): + id: str + type: str + title: str + message: str + created_at: datetime + is_read: bool + is_actionable: Optional[bool] = False + action_text: Optional[str] = None + +class AlertResponse(BaseModel): + alerts: List[AlertItem] + unread_count: int + timestamp: datetime + +class ProcessingLoadResponse(BaseModel): + completed: float + processing: float + queued: float diff --git a/app/service/face_embedding.py b/app/service/face_embedding.py index e5333a0..a571f19 100644 --- a/app/service/face_embedding.py +++ b/app/service/face_embedding.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +from collections.abc import AsyncIterable, AsyncIterator from dataclasses import dataclass from typing import List, Literal, Optional, Sequence, Tuple, TypedDict @@ -134,20 +135,27 @@ async def compute_average_embedding( self, payloads: Sequence[FaceImagePayload], ) -> list[float]: + async def iter_payloads() -> AsyncIterator[FaceImagePayload]: + for payload in payloads: + yield payload - if not payloads: - raise AppException.bad_request( - "At least one image is required for enrollment" - ) + return await self.compute_average_embedding_stream(iter_payloads()) + + async def compute_average_embedding_stream( + self, + payloads: AsyncIterable[FaceImagePayload], + ) -> list[float]: + has_payload = False embeddings: list[np.ndarray] = [] - for payload in payloads: + async for payload in payloads: + has_payload = True image = self._decode_image(payload) image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) # Single detection pass — model.get() already returns embeddings - faces: list[FaceStub] = await asyncio.to_thread( # type: ignore + faces: list[FaceStub] = await asyncio.to_thread( # type: ignore self.face_embedding.model.get, image_rgb # type: ignore ) @@ -165,6 +173,11 @@ async def compute_average_embedding( embeddings.append(face.embedding.astype(np.float32)) + if not has_payload: + raise AppException.bad_request( + "At least one image is required for enrollment" + ) + stacked = np.stack(embeddings, axis=0) averaged = np.mean(stacked, axis=0) diff --git a/app/service/photo_approval.py b/app/service/photo_approval.py index dd3f68e..f3eb809 100644 --- a/app/service/photo_approval.py +++ b/app/service/photo_approval.py @@ -64,6 +64,14 @@ async def decide( await self._photo_querier.update_photo_status(id=photo_id, status="approved") return "approved" + async def expire_stale(self, timeout_days: int) -> int: + count = 0 + async for _ in self._approval_querier.expire_stale_approvals(timeout_days=timeout_days): + count += 1 + if count: + logger.info("Auto-expired %d stale pending photo(s)", count) + return count + 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: diff --git a/app/service/staff_drive.py b/app/service/staff_drive.py index fb83da5..9cb6ba9 100644 --- a/app/service/staff_drive.py +++ b/app/service/staff_drive.py @@ -1,3 +1,4 @@ +import asyncio import base64 import hashlib import json @@ -236,44 +237,59 @@ async def import_images_from_drive( if not selected_files: return [] + if len(selected_files) > 1000: + raise AppException.bad_request( + "Cannot import more than 1000 files at once. Please select fewer files." + ) + access_token = await self.get_access_token_for_staff_user(staff_user.id) bucket = ImageBucket(f"{DRIVE_BUCKET_PREFIX}/{staff_user.id}") - results: list[DriveImportResult] = [] + semaphore = asyncio.Semaphore(10) - for selected in selected_files: + async def process_file(selected: SelectedDriveFile) -> DriveImportResult: if selected.mime_type and selected.mime_type not in IMAGE_ALLOWED_TYPES: raise AppException.bad_request( f"File '{selected.name}' has unsupported type '{selected.mime_type}'. " f"Allowed: {', '.join(sorted(IMAGE_ALLOWED_TYPES))}" ) - download = await GoogleDriveClient.download_file( - access_token=access_token, - file_id=selected.id, - ) - - if len(download.content) > MAX_IMPORT_FILE_SIZE_BYTES: - raise AppException.bad_request( - f"File '{selected.name}' exceeds the 20 MB size limit" + async with semaphore: + download = await GoogleDriveClient.download_file( + access_token=access_token, + file_id=selected.id, ) - object_name = self._generate_object_name(selected.name) - content_type = selected.mime_type or download.metadata.mime_type + if len(download.content) > MAX_IMPORT_FILE_SIZE_BYTES: + raise AppException.bad_request( + f"File '{selected.name}' exceeds the 20 MB size limit" + ) - await bucket.put_bytes( - data=download.content, - object_name=object_name, - content_type=content_type, - filename=selected.name, - ) + object_name = self._generate_object_name(selected.name) + content_type = selected.mime_type or download.metadata.mime_type + + await bucket.put_bytes( + data=download.content, + object_name=object_name, + content_type=content_type, + filename=selected.name, + ) - results.append(DriveImportResult( + return DriveImportResult( drive_file_id=selected.id, original_file_name=selected.name, minio_bucket=bucket.bucket_name, minio_object_name=object_name, minio_object_path=f"{bucket.file_prefix}/{object_name}", - )) + ) + + results: list[DriveImportResult] = [] + # Process in chunks of 50 to prevent creating too many asyncio Task objects in memory + chunk_size = 50 + for i in range(0, len(selected_files), chunk_size): + chunk = selected_files[i : i + chunk_size] + tasks = [process_file(selected) for selected in chunk] + chunk_results = await asyncio.gather(*tasks) + results.extend(chunk_results) return results diff --git a/app/service/staff_user.py b/app/service/staff_user.py index 6241818..3a589e4 100644 --- a/app/service/staff_user.py +++ b/app/service/staff_user.py @@ -24,8 +24,9 @@ async def create_staff_user( ) -> StaffUser: try: hashed_password = hash_password(password) + normalized_email = email.strip().lower() if email else None user = await self.staff_user_querier.create_multi( - email=email, + email=normalized_email, password=hashed_password, role=role, ) @@ -108,10 +109,10 @@ async def admin_login( email: str, password: str, ) -> WebAuthResponse: - print("hello") - staff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_email(email=email) + normalized_email = email.strip().lower() + staff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_email(email=normalized_email) if staff is None or not verify_password(password, staff.password): - logger.info("admin login failed for email %s", email) + logger.info("admin login failed for email %s", normalized_email) raise AppException.unauthorized("Invalid email or password") @@ -126,7 +127,7 @@ async def admin_login( role=staff.role, ) - async def Get_stuff_user( + async def get_staff_user( self, stuff_id:uuid.UUID )->StaffUser: diff --git a/app/service/stats.py b/app/service/stats.py new file mode 100644 index 0000000..b1b9f74 --- /dev/null +++ b/app/service/stats.py @@ -0,0 +1,77 @@ +from datetime import datetime, timezone +import uuid +from typing import TYPE_CHECKING +from app.schema.response.web.stats import ( + AdminStatsResponse, DriveUsageResponse, + ProcessingLoadResponse, AlertResponse, AlertItem +) + +if TYPE_CHECKING: + from db.generated.stats import AsyncQuerier + +class StatsService: + def __init__(self, querier: "AsyncQuerier"): + self.q = querier + + async def get_dashboard_stats(self) -> AdminStatsResponse: + active_events = await self.q.get_active_events_count() + photos = await self.q.get_total_photos_uploaded() + metrics = await self.q.get_processing_job_metrics() + + return AdminStatsResponse( + active_events=active_events or 0, + photos_uploaded=photos or 0, + processed_photos=metrics.completed_count if metrics else 0, + queue_size=metrics.pending_count if metrics else 0, + timestamp=datetime.now(timezone.utc) + ) + + async def get_processing_load(self) -> ProcessingLoadResponse: + metrics = await self.q.get_processing_job_metrics() + if not metrics: + return ProcessingLoadResponse(completed=0.0, processing=0.0, queued=0.0) + + total = metrics.completed_count + metrics.running_count + metrics.pending_count + + if total == 0: + return ProcessingLoadResponse(completed=0.0, processing=0.0, queued=0.0) + + return ProcessingLoadResponse( + completed=round((metrics.completed_count / total) * 100, 1), + processing=round((metrics.running_count / total) * 100, 1), + queued=round((metrics.pending_count / total) * 100, 1) + ) + + async def get_storage_usage(self) -> DriveUsageResponse: + used_bytes = await self.q.get_total_storage_bytes() + # Mock d'un total de 1TB (1000 Go) pour l'affichage Frontend + total_bytes = 1000 * 1024 * 1024 * 1024 + + return DriveUsageResponse( + used_bytes=used_bytes or 0, + total_bytes=total_bytes, + timestamp=datetime.now(timezone.utc) + ) + + async def get_staff_alerts(self, staff_id: uuid.UUID) -> AlertResponse: + db_alerts = [a async for a in self.q.get_recent_staff_alerts(staff_user_id=staff_id)] + unread_count = await self.q.get_unread_staff_alerts_count(staff_user_id=staff_id) + + alerts = [] + for a in db_alerts: + # Assuming payload is a dict with title and message + payload = a.payload or {} + alerts.append(AlertItem( + id=str(a.id), + type=a.type, + title=payload.get("title", "Notification"), + message=payload.get("message", "No message provided"), + created_at=a.created_at, + is_read=a.read_at is not None + )) + + return AlertResponse( + alerts=alerts, + unread_count=unread_count or 0, + timestamp=datetime.now(timezone.utc) + ) diff --git a/app/service/user_notification.py b/app/service/user_notification.py index a269ff1..f8181bc 100644 --- a/app/service/user_notification.py +++ b/app/service/user_notification.py @@ -1,3 +1,4 @@ +import json from typing import Any import uuid @@ -41,7 +42,7 @@ async def create_notification( notification_record = await self.notification_querier.create_notification( user_id=user_id, type=type, - payload=payload, + payload=json.dumps(payload), ) if notification_record is None: raise AppException.internal_error("Failed to create user notification") diff --git a/app/service/users.py b/app/service/users.py index 2237d8c..0ce7ca6 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -1,5 +1,6 @@ from datetime import datetime, timedelta, timezone import uuid +from collections.abc import AsyncIterable from typing import Optional from sqlalchemy.exc import SQLAlchemyError @@ -21,8 +22,12 @@ MobileAuthBaseRequest, MobileLoginRequest, MobileRegisterRequest, + RegisterVerifyRequest, ) -from app.schema.response.mobile.auth import MobileAuthResponse +from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse +from app.infra.nats import NatsClient +import secrets +import json from db.generated import user as user_queries from db.generated import devices as device_queries from db.generated import session as session_queries @@ -130,7 +135,7 @@ async def mobile_register( redis: RedisClient, req: MobileRegisterRequest, client_ip: Optional[str] = None, - ) -> MobileAuthResponse: + ) -> RegisterPendingResponse: logger.info("mobile_register attempt") max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS @@ -153,16 +158,105 @@ async def mobile_register( if existing_user is not None: logger.warning("register attempt: email_already_in_use") raise AppException.conflict("Email already in use; please login instead") + hashed = hash_password(req.password) - logger.info("register attempt: creating_new_user") + otp = "".join(secrets.choice("0123456789") for _ in range(6)) + + pending_key = f"pending_user:{req.email}" + pending_data = { + "hashed_password": hashed, + } + + # Save in Redis for 10 minutes (600 seconds) + await redis.set(pending_key, json.dumps(pending_data), expire=600) + await redis.set(f"otp:{req.email}", otp, expire=600) + + # Send to NATS + await NatsClient.publish("email.send_otp", json.dumps({"email": req.email, "otp": otp}).encode("utf-8")) + + logger.info("register success, OTP sent to %s", req.email) + return RegisterPendingResponse( + message="OTP sent to email", + status="pending_verification", + email=req.email + ) + + async def mobile_register_resend_otp( + self, + redis: RedisClient, + email: str, + client_ip: Optional[str] = None, + ) -> RegisterPendingResponse: + logger.info("resend_otp attempt for %s", email) + max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS + window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS + + if client_ip: + await self.check_rate_limit( + redis, + f"rate:ip:{client_ip}", + max_attempts, + window, + ) + await self.check_rate_limit( + redis, + f"rate:email:{email}", + max_attempts, + window, + ) + + pending_key = f"pending_user:{email}" + raw_data = await redis.get(pending_key) + if not raw_data: + raise AppException.not_found("No pending registration found for this email") + + otp = "".join(secrets.choice("0123456789") for _ in range(6)) + + # Regenerate OTP with 10 mins TTL, without touching the pending_user TTL + await redis.set(f"otp:{email}", otp, expire=600) + + # Send to NATS + await NatsClient.publish("email.send_otp", json.dumps({"email": email, "otp": otp}).encode("utf-8")) + + logger.info("resend_otp success, new OTP sent to %s", email) + return RegisterPendingResponse( + message="New OTP sent to email", + status="pending_verification", + email=email + ) + + async def verify_mobile_register( + self, + redis: RedisClient, + req: RegisterVerifyRequest, + client_ip: Optional[str] = None, + ) -> MobileAuthResponse: + otp_key = f"otp:{req.email}" + stored_otp = await redis.get(otp_key) + + if not stored_otp or stored_otp != req.otp: + raise AppException.unauthorized("Invalid or expired OTP") + + pending_key = f"pending_user:{req.email}" + raw_data = await redis.get(pending_key) + if not raw_data: + raise AppException.unauthorized("Registration session expired") + + data = json.loads(raw_data) + try: - user = await self.user_querier.create_user(email=req.email, hashed_password=hashed) + user = await self.user_querier.create_user(email=req.email, hashed_password=data["hashed_password"]) if not user: raise AppException.internal_error("Failed to create user") except SQLAlchemyError as exc: logger.error("Failed to create user: %s", exc) raise DBException.handle(exc) - logger.info("register success user_id=%s", user.id) + + # Clean up redis + await redis.delete(otp_key) + await redis.delete(pending_key) + + logger.info("register verify success user_id=%s", user.id) return await self._create_mobile_session( redis=redis, user=user, @@ -216,7 +310,6 @@ async def _create_mobile_session( expiry = Get_expiry_time() logger.info("session_created session_id=%s user_id=%s", session.id, user_id) - # Populate Redis auth cache for fast-path validation await SessionService.cache_session_for_auth( redis=redis, session_id=session.id, @@ -286,14 +379,33 @@ async def logout( async def add_embbed_user( self, user_id: uuid.UUID, - image_payloads: list[FaceImagePayload], + image_payloads: AsyncIterable[FaceImagePayload], ) -> User: logger.info("Generating face embeddings for user %s", user_id) - averaging = await self.face_embedding_service.compute_average_embedding( + existing = await self.user_querier.get_user_by_id(id=user_id) + if not existing: + raise AppException.not_found("User not found") + if existing.face_embedding is not None: + raise AppException.conflict( + "User already has an active face enrollment. " + "Delete the existing enrollment before re-enrolling." + ) + + averaging = await self.face_embedding_service.compute_average_embedding_stream( image_payloads ) vector_literal = "[" + ", ".join(str(x) for x in averaging) + "]" + + locked_existing = await self.user_querier.get_user_by_id_for_update(id=user_id) + if not locked_existing: + raise AppException.not_found("User not found") + if locked_existing.face_embedding is not None: + raise AppException.conflict( + "User already has an active face enrollment. " + "Delete the existing enrollment before re-enrolling." + ) + user = await self.user_querier.set_user_embedding( dollar_1=vector_literal, id=user_id, @@ -410,7 +522,6 @@ async def delete_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: session_key = constant.RedisKey.UserSessionByUser.value.format( user_id=user_id ) - # Best-effort: also invalidate the per-session MobileSessionCache. raw_session_id = await redis.get(session_key) if raw_session_id: try: @@ -431,15 +542,13 @@ async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User: raise AppException.not_found("User not found") session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id) - # Best-effort: retrieve the session_id from UserSessionByUser cache to also - # invalidate the per-session MobileSessionCache entry. raw_session_id = await redis.get(session_key) if raw_session_id: try: session_id = uuid.UUID(raw_session_id) await SessionService.delete_session_cache(redis=redis, session_id=session_id) except (ValueError, Exception): - pass # non-blocking: session cache will expire naturally + pass await redis.delete(session_key) return user @@ -474,9 +583,9 @@ async def check_rate_limit( ) -> None: """Enforce rate limiting using Redis INCR + EXPIRE. - Increments a counter for ``key``. On the first increment the key + Increments a counter for ``key``. On the first increment the key is given a TTL of ``window_seconds`` so the window resets - automatically. If the counter exceeds ``max_requests`` a 429 + automatically. If the counter exceeds ``max_requests`` a 429 response is raised with a ``Retry-After`` header. """ current_count = await redis.incr(key) diff --git a/app/worker/email_worker/main.py b/app/worker/email_worker/main.py new file mode 100644 index 0000000..129d1ab --- /dev/null +++ b/app/worker/email_worker/main.py @@ -0,0 +1,62 @@ +import asyncio +import json + +from app.core.config import settings +from app.core.logger import logger +from app.infra.nats import NatsClient +from app.infra.email import EmailSender + + +async def handle_message(raw_payload: bytes | str) -> None: + try: + if isinstance(raw_payload, bytes): + raw_payload = raw_payload.decode() + + payload = json.loads(raw_payload) + email = payload.get("email") + otp = payload.get("otp") + + if not email or not otp: + logger.error("Invalid email.send_otp payload: %s", raw_payload) + return + + success = await EmailSender.send_otp_email(to_email=email, otp=otp) + if success: + logger.info("Successfully sent OTP email to %s", email) + else: + logger.error("Failed to send OTP email to %s", email) + + except Exception: + logger.exception("Unexpected error in email worker") + + +async def run_worker() -> None: + logger.info("Email worker started") + + async def wrapped_handler(msg: bytes | str) -> None: + await handle_message(msg) + + # Subscribe to the email.send_otp subject + await NatsClient.subscribe("email.send_otp", wrapped_handler) + + # Keep the worker running + await asyncio.Event().wait() + + +async def main() -> None: + await NatsClient.connect( + host=settings.NATS_HOST, + port=settings.NATS_PORT, + user=settings.NATS_USER, + password=settings.NATS_PASSWORD, + ) + + try: + await run_worker() + finally: + await NatsClient.close() + logger.info("Email Worker shutdown") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/worker/photo_worker/main.py b/app/worker/photo_worker/main.py index ba7e960..15e0586 100644 --- a/app/worker/photo_worker/main.py +++ b/app/worker/photo_worker/main.py @@ -126,6 +126,8 @@ async def _handle_single_face(self, event: PhotoProcessEvent, face: DetectedFace 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)) + approvals_created = 0 + for face_index, face in enumerate(faces): bbox_json = json.dumps({ "x1": float(face.bbox[0]), @@ -151,6 +153,8 @@ async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[Detect logger.info("No match for face %d in photo %s", face_index, event.photo_id) continue + approvals_created += 1 + try: await self._notification_service.create_notification( user_id=approval.user_id, @@ -174,6 +178,11 @@ async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[Detect approval.user_id, event.photo_id, exc, ) + if approvals_created == 0: + logger.info("No users matched in group photo %s, auto-approving 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") + async def _create_job(self, event: PhotoProcessEvent) -> models.ProcessingJob | None: if self._pj_querier is None: diff --git a/db/generated/audit.py b/db/generated/audit.py index 47bf3f4..b5cdd56 100644 --- a/db/generated/audit.py +++ b/db/generated/audit.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: audit.sql import dataclasses import datetime diff --git a/db/generated/devices.py b/db/generated/devices.py index e90ebdd..4f744d1 100644 --- a/db/generated/devices.py +++ b/db/generated/devices.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: devices.sql import dataclasses from typing import Any, AsyncIterator, Optional diff --git a/db/generated/eventParticipant.py b/db/generated/eventParticipant.py index 0fcc26c..0e6fd9c 100644 --- a/db/generated/eventParticipant.py +++ b/db/generated/eventParticipant.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: eventParticipant.sql import dataclasses import datetime diff --git a/db/generated/events.py b/db/generated/events.py index 1ac8999..0395bfd 100644 --- a/db/generated/events.py +++ b/db/generated/events.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: events.sql import dataclasses import datetime diff --git a/db/generated/models.py b/db/generated/models.py index 7943214..07418cb 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 import dataclasses import datetime import enum diff --git a/db/generated/notifications.py b/db/generated/notifications.py index 3166cd3..543158d 100644 --- a/db/generated/notifications.py +++ b/db/generated/notifications.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: notifications.sql from typing import Any, AsyncIterator, Optional import uuid diff --git a/db/generated/photo_approvals.py b/db/generated/photo_approvals.py index f712392..56ba3a3 100644 --- a/db/generated/photo_approvals.py +++ b/db/generated/photo_approvals.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: photo_approvals.sql from typing import AsyncIterator, Optional import uuid @@ -23,6 +23,25 @@ """ +EXPIRE_STALE_APPROVALS = """-- name: expire_stale_approvals \\:many +WITH stale_photos AS ( +SELECT id FROM photos +WHERE status = 'pending' +AND created_at < now() - make_interval(days => :p1\\:\\:int) +), +_update_approvals AS ( +UPDATE photo_approvals +SET decision = 'approved', decided_at = now() +WHERE photo_id IN (SELECT id FROM stale_photos) +AND decision = 'pending' +) +UPDATE photos +SET status = 'approved' +WHERE id IN (SELECT id FROM stale_photos) +RETURNING id +""" + + 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 """ @@ -61,6 +80,11 @@ async def create_photo_approval(self, *, photo_id: uuid.UUID, user_id: uuid.UUID decided_at=row[4], ) + async def expire_stale_approvals(self, *, timeout_days: int) -> AsyncIterator[uuid.UUID]: + result = await self._conn.stream(sqlalchemy.text(EXPIRE_STALE_APPROVALS), {"p1": timeout_days}) + async for row in result: + yield row[0] + 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: diff --git a/db/generated/photo_faces.py b/db/generated/photo_faces.py index 507e6c6..ae023b9 100644 --- a/db/generated/photo_faces.py +++ b/db/generated/photo_faces.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: photo_faces.sql import dataclasses from typing import Any, Optional @@ -79,6 +79,7 @@ class InsertPhotoFaceWithApprovalParams: inserted_match AS ( INSERT INTO face_matches (photo_face_id, user_id, confidence) SELECT upserted_photo_face.id, :p5, :p6 + FROM upserted_photo_face WHERE NOT EXISTS (SELECT 1 FROM existing_match) RETURNING id ) diff --git a/db/generated/photos.py b/db/generated/photos.py index 2c14a10..f757a9f 100644 --- a/db/generated/photos.py +++ b/db/generated/photos.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: photos.sql import dataclasses import datetime @@ -62,14 +62,22 @@ class CreatePhotoParams: 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 +SELECT 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) +AND p.status = 'approved' +AND ( + p.visibility = 'public' + OR EXISTS ( + SELECT 1 FROM photo_faces pf + JOIN face_matches fm ON fm.photo_face_id = pf.id + WHERE pf.photo_id = p.id AND fm.user_id = :p1 + ) + OR EXISTS ( + SELECT 1 FROM photo_approvals pa + WHERE pa.photo_id = p.id AND 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 @@ -87,13 +95,20 @@ class ListEventPhotosForUserParams: 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 +SELECT 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) +WHERE ( + EXISTS ( + SELECT 1 FROM photo_faces pf + JOIN face_matches fm ON fm.photo_face_id = pf.id + WHERE pf.photo_id = p.id AND fm.user_id = :p1 + ) + OR EXISTS ( + SELECT 1 FROM photo_approvals pa + WHERE pa.photo_id = p.id AND 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 diff --git a/db/generated/processing_jobs.py b/db/generated/processing_jobs.py index 0cde67b..beeb0ef 100644 --- a/db/generated/processing_jobs.py +++ b/db/generated/processing_jobs.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: processing_jobs.sql from typing import Any, Optional import uuid @@ -26,11 +26,11 @@ """ -UPDATE_PROCESSING_JOB_STATUS = """-- name: update_processing_job_status \:one +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'::processing_job_status, 'failed'::processing_job_status) THEN now() ELSE completed_at END + completed_at = CASE WHEN :p2 IN ('completed'\\:\\:processing_job_status, 'failed'\\:\\:processing_job_status) THEN now() ELSE completed_at END WHERE id = :p1 RETURNING id, photo_id, job_type, status, attempts, created_at, completed_at """ diff --git a/db/generated/session.py b/db/generated/session.py index bc7b427..ee80322 100644 --- a/db/generated/session.py +++ b/db/generated/session.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: session.sql import dataclasses import datetime diff --git a/db/generated/staff_drive_connections.py b/db/generated/staff_drive_connections.py index 941e8b7..2394bc7 100644 --- a/db/generated/staff_drive_connections.py +++ b/db/generated/staff_drive_connections.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: staff_drive_connections.sql import dataclasses import datetime diff --git a/db/generated/staff_notifications.py b/db/generated/staff_notifications.py index 1c285f8..cd50add 100644 --- a/db/generated/staff_notifications.py +++ b/db/generated/staff_notifications.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: staff_notifications.sql from typing import Any, AsyncIterator, Optional import uuid diff --git a/db/generated/stats.py b/db/generated/stats.py new file mode 100644 index 0000000..690bf20 --- /dev/null +++ b/db/generated/stats.py @@ -0,0 +1,107 @@ +# Code generated by sqlc. DO NOT EDIT. +# versions: +# sqlc v1.31.1 +# source: stats.sql +import dataclasses +from typing import AsyncIterator, Optional +import uuid + +import sqlalchemy +import sqlalchemy.ext.asyncio + +from db.generated import models + + +GET_ACTIVE_EVENTS_COUNT = """-- name: get_active_events_count \\:one +SELECT COUNT(*) FROM events WHERE status = 'scheduled' +""" + + +GET_PROCESSING_JOB_METRICS = """-- name: get_processing_job_metrics \\:one +SELECT + COUNT(*) FILTER (WHERE status = 'completed')\\:\\:int AS completed_count, + COUNT(*) FILTER (WHERE status = 'running')\\:\\:int AS running_count, + COUNT(*) FILTER (WHERE status = 'pending')\\:\\:int AS pending_count +FROM processing_jobs +""" + + +@dataclasses.dataclass() +class GetProcessingJobMetricsRow: + completed_count: int + running_count: int + pending_count: int + + +GET_RECENT_STAFF_ALERTS = """-- name: get_recent_staff_alerts \\:many +SELECT id, staff_user_id, type, payload, read_at, created_at FROM staff_notifications +WHERE staff_user_id = :p1 +ORDER BY created_at DESC LIMIT 10 +""" + + +GET_TOTAL_PHOTOS_UPLOADED = """-- name: get_total_photos_uploaded \\:one +SELECT COUNT(*) FROM photos +""" + + +GET_TOTAL_STORAGE_BYTES = """-- name: get_total_storage_bytes \\:one +SELECT COALESCE(SUM(size_bytes), 0)\\:\\:bigint FROM upload_request_photos +""" + + +GET_UNREAD_STAFF_ALERTS_COUNT = """-- name: get_unread_staff_alerts_count \\:one +SELECT COUNT(*) FROM staff_notifications +WHERE staff_user_id = :p1 AND read_at IS NULL +""" + + +class AsyncQuerier: + def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): + self._conn = conn + + async def get_active_events_count(self) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(GET_ACTIVE_EVENTS_COUNT))).first() + if row is None: + return None + return row[0] + + async def get_processing_job_metrics(self) -> Optional[GetProcessingJobMetricsRow]: + row = (await self._conn.execute(sqlalchemy.text(GET_PROCESSING_JOB_METRICS))).first() + if row is None: + return None + return GetProcessingJobMetricsRow( + completed_count=row[0], + running_count=row[1], + pending_count=row[2], + ) + + async def get_recent_staff_alerts(self, *, staff_user_id: uuid.UUID) -> AsyncIterator[models.StaffNotification]: + result = await self._conn.stream(sqlalchemy.text(GET_RECENT_STAFF_ALERTS), {"p1": staff_user_id}) + async for row in result: + yield models.StaffNotification( + id=row[0], + staff_user_id=row[1], + type=row[2], + payload=row[3], + read_at=row[4], + created_at=row[5], + ) + + async def get_total_photos_uploaded(self) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(GET_TOTAL_PHOTOS_UPLOADED))).first() + if row is None: + return None + return row[0] + + async def get_total_storage_bytes(self) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(GET_TOTAL_STORAGE_BYTES))).first() + if row is None: + return None + return row[0] + + async def get_unread_staff_alerts_count(self, *, staff_user_id: uuid.UUID) -> Optional[int]: + row = (await self._conn.execute(sqlalchemy.text(GET_UNREAD_STAFF_ALERTS_COUNT), {"p1": staff_user_id})).first() + if row is None: + return None + return row[0] diff --git a/db/generated/stuff_user.py b/db/generated/stuff_user.py index 1337565..d541078 100644 --- a/db/generated/stuff_user.py +++ b/db/generated/stuff_user.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: stuff_user.sql import dataclasses from typing import Any, AsyncIterator, Optional diff --git a/db/generated/upload_request_groups.py b/db/generated/upload_request_groups.py index dacf93d..039b1f0 100644 --- a/db/generated/upload_request_groups.py +++ b/db/generated/upload_request_groups.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: upload_request_groups.sql import dataclasses from typing import Any, AsyncIterator, Optional diff --git a/db/generated/upload_request_photos.py b/db/generated/upload_request_photos.py index 2180eab..1cd3ebb 100644 --- a/db/generated/upload_request_photos.py +++ b/db/generated/upload_request_photos.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: upload_request_photos.sql import dataclasses import datetime diff --git a/db/generated/upload_requests.py b/db/generated/upload_requests.py index db4887e..b0da8bb 100644 --- a/db/generated/upload_requests.py +++ b/db/generated/upload_requests.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: upload_requests.sql import dataclasses from typing import Any, AsyncIterator, Optional diff --git a/db/generated/user.py b/db/generated/user.py index b236857..d0ab815 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -1,6 +1,6 @@ # Code generated by sqlc. DO NOT EDIT. # versions: -# sqlc v1.30.0 +# sqlc v1.31.1 # source: user.sql import dataclasses from typing import Any, AsyncIterator, Optional @@ -55,6 +55,14 @@ class FindClosestUserByEmbeddingRow: """ +GET_USER_BY_ID_FOR_UPDATE = """-- name: get_user_by_id_for_update \\:one +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked +FROM users +WHERE id = :p1 +FOR UPDATE +""" + + LIST_USERS = """-- name: list_users \\:many SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked FROM users @@ -179,6 +187,22 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: blocked=row[8], ) + async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(GET_USER_BY_ID_FOR_UPDATE), {"p1": 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 list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: result = await self._conn.stream(sqlalchemy.text(LIST_USERS), {"p1": limit, "p2": offset}) async for row in result: diff --git a/db/queries/photo_approvals.sql b/db/queries/photo_approvals.sql index ac6b787..52e6ec6 100644 --- a/db/queries/photo_approvals.sql +++ b/db/queries/photo_approvals.sql @@ -17,6 +17,23 @@ RETURNING *; -- name: GetPhotoApprovalsByPhotoId :many SELECT * FROM photo_approvals WHERE photo_id = $1; +-- name: ExpireStaleApprovals :many +WITH stale_photos AS ( +SELECT id FROM photos +WHERE status = 'pending' +AND created_at < now() - make_interval(days => sqlc.arg('timeout_days')::int) +), +_update_approvals AS ( +UPDATE photo_approvals +SET decision = 'approved', decided_at = now() +WHERE photo_id IN (SELECT id FROM stale_photos) +AND decision = 'pending' +) +UPDATE photos +SET status = 'approved' +WHERE id IN (SELECT id FROM stale_photos) +RETURNING id; + -- name: ListApprovalsByUserAndStatus :many SELECT * FROM photo_approvals WHERE user_id = $1 diff --git a/db/queries/photo_faces.sql b/db/queries/photo_faces.sql index a6286d9..5fab5ce 100644 --- a/db/queries/photo_faces.sql +++ b/db/queries/photo_faces.sql @@ -66,6 +66,7 @@ existing_match AS ( inserted_match AS ( INSERT INTO face_matches (photo_face_id, user_id, confidence) SELECT upserted_photo_face.id, $5, $6 + FROM upserted_photo_face WHERE NOT EXISTS (SELECT 1 FROM existing_match) RETURNING id ) diff --git a/db/queries/photos.sql b/db/queries/photos.sql index bd5ac56..993c397 100644 --- a/db/queries/photos.sql +++ b/db/queries/photos.sql @@ -26,27 +26,42 @@ WHERE id = $1 RETURNING *; -- name: ListUserPhotos :many -SELECT DISTINCT p.* +SELECT 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) +WHERE ( + EXISTS ( + SELECT 1 FROM photo_faces pf + JOIN face_matches fm ON fm.photo_face_id = pf.id + WHERE pf.photo_id = p.id AND fm.user_id = $1 + ) + OR EXISTS ( + SELECT 1 FROM photo_approvals pa + WHERE pa.photo_id = p.id AND 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.* +SELECT 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) +AND p.status = 'approved' +AND ( + p.visibility = 'public' + OR EXISTS ( + SELECT 1 FROM photo_faces pf + JOIN face_matches fm ON fm.photo_face_id = pf.id + WHERE pf.photo_id = p.id AND fm.user_id = $1 + ) + OR EXISTS ( + SELECT 1 FROM photo_approvals pa + WHERE pa.photo_id = p.id AND 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 diff --git a/db/queries/stats.sql b/db/queries/stats.sql new file mode 100644 index 0000000..fd4eb7e --- /dev/null +++ b/db/queries/stats.sql @@ -0,0 +1,24 @@ +-- name: GetActiveEventsCount :one +SELECT COUNT(*) FROM events WHERE status = 'scheduled'; + +-- name: GetTotalPhotosUploaded :one +SELECT COUNT(*) FROM photos; + +-- name: GetProcessingJobMetrics :one +SELECT + COUNT(*) FILTER (WHERE status = 'completed')::int AS completed_count, + COUNT(*) FILTER (WHERE status = 'running')::int AS running_count, + COUNT(*) FILTER (WHERE status = 'pending')::int AS pending_count +FROM processing_jobs; + +-- name: GetTotalStorageBytes :one +SELECT COALESCE(SUM(size_bytes), 0)::bigint FROM upload_request_photos; + +-- name: GetRecentStaffAlerts :many +SELECT * FROM staff_notifications +WHERE staff_user_id = $1 +ORDER BY created_at DESC LIMIT 10; + +-- name: GetUnreadStaffAlertsCount :one +SELECT COUNT(*) FROM staff_notifications +WHERE staff_user_id = $1 AND read_at IS NULL; diff --git a/db/queries/user.sql b/db/queries/user.sql index a46577b..fc906dd 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -8,6 +8,12 @@ SELECT * FROM users WHERE id = $1; +-- name: GetUserByIdForUpdate :one +SELECT * +FROM users +WHERE id = $1 +FOR UPDATE; + -- name: GetUserByEmail :one SELECT * FROM users diff --git a/docker-compose.staging.local.yml b/docker-compose.staging.local.yml index 135eb75..0d3c378 100644 --- a/docker-compose.staging.local.yml +++ b/docker-compose.staging.local.yml @@ -14,5 +14,13 @@ services: dockerfile: Dockerfile pull_policy: never + email-worker: + build: + context: . + dockerfile: Dockerfile + pull_policy: never + volumes: + - ./:/app + volumes: insightface_cache: diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index be3ff4c..93435d8 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -96,6 +96,19 @@ services: networks: - multi_network + email-worker: + image: ghcr.io/microclub-usthb/multai-back:latest + container_name: multi_email_worker + restart: unless-stopped + env_file: + - .env.staging + depends_on: + - nats + - redis + command: ["uv", "run", "python", "-m", "app.worker.email_worker.main"] + networks: + - multi_network + volumes: postgres_data: minio_data: diff --git a/mobile-quickstart/.env.mobile.example b/mobile-quickstart/.env.mobile.example new file mode 100644 index 0000000..f3cecb5 --- /dev/null +++ b/mobile-quickstart/.env.mobile.example @@ -0,0 +1,61 @@ + +# ========================= +# PostgreSQL +# ========================= +POSTGRES_USER=multi +POSTGRES_PASSWORD=multi_pass +POSTGRES_DB=multi_ai +POSTGRES_PORT=5432 +POSTGRES_HOST=postgres + +# ========================= +# NATS +# ========================= +NATS_PORT=4222 +NATS_MONITOR_PORT=8222 +NATS_HOST=nats +NATS_USER=testuser +NATS_PASSWORD=testpassword + +# ========================= +# MinIO +# ========================= +MINIO_ROOT_USER=minio +MINIO_ROOT_PASSWORD=minio_pass +MINIO_API_PORT=9000 +MINIO_CONSOLE_PORT=9001 +MINIO_HOST=minio + +# ========================= +# Redis +# ========================= +REDIS_PORT=6379 +REDIS_HOST=redis +REDIS_PASSWORD= + +# ========================= +# Auth +# ========================= +jwt_secret=super_secret_jwt_key +jwt_algorithm=HS256 +encryption_key=super_secret_encryption_key +totp_issuer=MultiAI +FACE_ENCRYPTION_KEY=hkbribvfirirbvivbibvib + +# ========================= +# Google OAuth +# ========================= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI=http://127.0.0.1:8000/staff/drive/callback +GOOGLE_OAUTH_SCOPES=https://www.googleapis.com/auth/drive.readonly openid email profile + +# ========================= +# CORS +# ========================= +CORS_ORIGINS=["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173"] + +# ========================= +# Firebase +# ========================= +FIREBASE_CREDENTIALS_PATH= diff --git a/mobile-quickstart/docker-compose.mobile.yml b/mobile-quickstart/docker-compose.mobile.yml new file mode 100644 index 0000000..bc65f66 --- /dev/null +++ b/mobile-quickstart/docker-compose.mobile.yml @@ -0,0 +1,85 @@ +services: + postgres: + image: pgvector/pgvector:pg16 + restart: unless-stopped + env_file: .env.mobile + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U multi -d multi_ai"] + interval: 5s + retries: 10 + networks: + - multi_network + + redis: + image: redis:7-alpine + restart: unless-stopped + ports: + - "6379:6379" + networks: + - multi_network + + minio: + image: minio/minio:latest + restart: unless-stopped + env_file: .env.mobile + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + networks: + - multi_network + + nats: + image: nats:2.10-alpine + command: > + -js + -a 0.0.0.0 + -m 8222 + --user testuser + --pass testpassword + ports: + - "4222:4222" + networks: + - multi_network + + migrate: + image: ghcr.io/microclub-usthb/multai-back:latest + env_file: .env.mobile + depends_on: + postgres: + condition: service_healthy + command: ["uv", "run", "alembic", "upgrade", "head"] + networks: + - multi_network + + fastapi: + image: ghcr.io/microclub-usthb/multai-back:latest + restart: unless-stopped + env_file: .env.mobile + depends_on: + migrate: + condition: service_completed_successfully + redis: + condition: service_started + minio: + condition: service_started + nats: + condition: service_started + ports: + - "8000:8000" + networks: + - multi_network + +volumes: + postgres_data: + minio_data: + +networks: + multi_network: + driver: bridge \ No newline at end of file diff --git a/mobile-quickstart/readme.md b/mobile-quickstart/readme.md new file mode 100644 index 0000000..7232b0a --- /dev/null +++ b/mobile-quickstart/readme.md @@ -0,0 +1,146 @@ +# multAI — Mobile Dev Quickstart + +This folder contains everything you need to run the multAI backend locally. You do not need to clone the repo or install Python. + +--- + +## Requirements + +* **Docker Desktop** ([Download here](https://www.docker.com/products/docker-desktop)) +* That is all! + +--- + +## Folder contents + +* `docker-compose.mobile.yml` — defines all backend services +* `.env.mobile` — environment variables (copy from `.env.mobile.example`) +* `seed.py` — database seed script +* `.mypy_cache/` — local Python type-check cache (safe to ignore/delete) +* `README.md` — this file + +--- + +## First time setup + +### 1. Copy the env file + +```bash +cp .env.mobile.example .env.mobile +``` + +### 2. Start all services + +```bash +docker compose -f docker-compose.mobile.yml up -d +``` + +This pulls and starts the following containers: + +* **postgres** — the database +* **redis** — caching and session storage +* **minio** — photo file storage +* **nats** — message broker for background jobs +* **migrate** — runs database migrations once then exits +* **fastapi** — the API server + +### 3. Copy the seed script into the container + +```bash +docker cp seed.py multai-mobile-test-fastapi-1:/app/seed.py +``` + +> **Note:** Wait for all containers to show as running before doing this. + +### 4. Seed the database + +```bash +docker compose -f docker-compose.mobile.yml exec fastapi uv run python seed.py +``` + +This creates all the test data you need: users, events, photos, and notifications. + +--- + +## Credentials + +After seeding, the summary printed in the terminal shows all credentials. The default ones are: + +### Mobile users +*(Use these to log in on the app)* + +* `alice@example.com` / `Alice123!` +* `bob@example.com` / `Bob1234!` + +### Staff users +*(For testing staff endpoints)* + +* `admin@multai.dev` / `Admin1234!` (role: `admin`) +* `lead@multai.dev` / `Lead1234!` (role: `multi_team_lead`) +* `multi@multai.dev` / `Multi1234!` (role: `multi`) + +### Events + +* **Tech Conference 2025** — Join code: `TECH2025` +* **Annual Gala** — Join code: `GALA2025` + +--- + +## Useful URLs + +* **API docs:** http://localhost:8000/docs +--- + +## What the seed creates + +* 2 mobile users with devices and active sessions +* 3 staff users +* 2 events with both users already joined +* 8 approved public photos uploaded to MinIO (4 per event) +* Face matches and photo approvals so gallery endpoints return results immediately +* Welcome notifications for each user +* Completed processing jobs so pipeline status endpoints look healthy +* Upload request groups for staff review endpoints + +--- + +## Daily workflow + +**Start the backend:** + +```bash +docker compose -f docker-compose.mobile.yml up -d +``` + +**Stop the backend:** + +```bash +docker compose -f docker-compose.mobile.yml down +``` + +--- + +## Reset everything +If you want a completely clean state with the latest image: +```bash +docker compose -f docker-compose.mobile.yml down -v +docker compose -f docker-compose.mobile.yml pull +docker compose -f docker-compose.mobile.yml up -d +docker compose -f docker-compose.mobile.yml ps +docker cp seed.py multai-mobile-test-fastapi-1:/app/seed.py +docker compose -f docker-compose.mobile.yml exec fastapi uv run python seed.py +``` +> `down -v` removes all volumes. `pull` grabs the latest backend image before recreating containers. Check the `ps` output shows all containers as `Up`/`Healthy` before running the seed step — copying the seed script too early (before the fastapi container is ready) will fail. + +--- + +## Update to the latest backend +When the backend team pushes a new version: +```bash +docker compose -f docker-compose.mobile.yml pull +docker compose -f docker-compose.mobile.yml up -d +docker compose -f docker-compose.mobile.yml ps +docker cp seed.py multai-mobile-test-fastapi-1:/app/seed.py +docker compose -f docker-compose.mobile.yml exec fastapi uv run python seed.py +``` +> Re-copy the seed script after updating since the container is recreated. Run `ps` first to confirm the new container is actually up before copying/seeding. \ No newline at end of file diff --git a/mobile-quickstart/seed.py b/mobile-quickstart/seed.py new file mode 100644 index 0000000..3f981fe --- /dev/null +++ b/mobile-quickstart/seed.py @@ -0,0 +1,588 @@ +""" +multAI backend seed script + +What this does: + - Creates staff users (admin, lead, multi roles) + - Creates mobile users (alice, bob) with devices and sessions + - Creates 2 events and joins all users to them + - Uploads placeholder photos to MinIO and inserts them as approved + - Creates face matches and photo approvals so gallery endpoints return results + - Creates notifications for all users + - Creates processing jobs marked complete so pipeline endpoints look healthy + - Creates upload request groups and requests for staff review endpoints + +Usage: + docker compose -f docker-compose.mobile.yml exec fastapi uv run python seed.py + docker compose -f docker-compose.mobile.yml exec fastapi uv run python seed.py --reset +""" + +import asyncio +import io +import random +import sys +import uuid +from datetime import datetime, timedelta, timezone + +import asyncpg # type: ignore[import-untyped] +from dotenv import load_dotenv +from miniopy_async.api import Minio +from PIL import Image, ImageDraw + +from app.core.config import settings +from app.core.securite import hash_password + +load_dotenv() + +# --------------------------------------------------------------------------- +# Seed data +# --------------------------------------------------------------------------- + +STAFF_USERS = [ + {"email": "admin@multai.dev", "password": "Admin1234!", "role": "admin"}, + {"email": "lead@multai.dev", "password": "Lead1234!", "role": "multi_team_lead"}, + {"email": "multi@multai.dev", "password": "Multi1234!", "role": "multi"}, +] + +MOBILE_USERS = [ + {"email": "alice@example.com", "password": "Alice123!", "display_name": "Alice"}, + {"email": "bob@example.com", "password": "Bob1234!", "display_name": "Bob"}, +] + +EVENTS = [ + { + "name": "Tech Conference 2025", + "event_code": "TECH2025", + "event_date": datetime(2025, 9, 15, 9, 0, tzinfo=timezone.utc), + "status": "scheduled", + }, + { + "name": "Annual Gala", + "event_code": "GALA2025", + "event_date": datetime(2025, 12, 20, 19, 0, tzinfo=timezone.utc), + "status": "scheduled", + }, +] + +PHOTOS_PER_EVENT = 4 +IMAGES_BUCKET = "images" + +PHOTO_COLORS = [ + (52, 152, 219), + (46, 204, 113), + (231, 76, 60), + (155, 89, 182), + (241, 196, 15), + (230, 126, 34), + (26, 188, 156), + (52, 73, 94), +] + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def now() -> datetime: + return datetime.now(timezone.utc) + + +def future(days: int) -> datetime: + return now() + timedelta(days=days) + + +def make_storage_key(event_id: uuid.UUID, index: int) -> str: + return f"seed/events/{event_id}/photo_{index}.jpg" + + +def generate_placeholder_image(label: str, color: tuple[int, int, int]) -> bytes: + img = Image.new("RGB", (800, 600), color=color) + draw = ImageDraw.Draw(img) + bbox = draw.textbbox((0, 0), label) + w, h = bbox[2] - bbox[0], bbox[3] - bbox[1] + draw.text(((800 - w) / 2, (600 - h) / 2), label, fill=(255, 255, 255)) + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=85) + return buf.getvalue() + + +# --------------------------------------------------------------------------- +# Reset +# --------------------------------------------------------------------------- + +async def reset_db(conn: asyncpg.Connection) -> None: + print("Resetting database...") + tables = [ + "audit_events", "face_matches", "photo_faces", "photo_approvals", + "user_photos", "processing_jobs", "upload_request_photos", + "upload_requests", "upload_request_groups", "notifications", + "staff_notifications", "staff_drive_connections", "event_participants", + "user_sessions", "user_devices", "photos", "events", + "users", "staff_users", + ] + for table in tables: + await conn.execute(f"DELETE FROM {table}") + print(f" cleared {table}") + print() + + +async def reset_minio(minio: Minio) -> None: + print("Clearing MinIO seed objects...") + objects = minio.list_objects(IMAGES_BUCKET, prefix="seed/", recursive=True) + async for obj in objects: + await minio.remove_object(IMAGES_BUCKET, obj.object_name) + print(" MinIO seed objects cleared\n") + + +# --------------------------------------------------------------------------- +# MinIO +# --------------------------------------------------------------------------- + +async def init_minio(minio: Minio) -> None: + print("Setting up MinIO buckets...") + for bucket in [IMAGES_BUCKET, "documents"]: + if not await minio.bucket_exists(bucket): + await minio.make_bucket(bucket) + print(f" created bucket: {bucket}") + else: + print(f" bucket exists: {bucket}") + print() + + +async def upload_photo( + minio: Minio, + storage_key: str, + label: str, + color: tuple[int, int, int], +) -> None: + image_bytes = generate_placeholder_image(label, color) + await minio.put_object( + bucket_name=IMAGES_BUCKET, + object_name=storage_key, + data=io.BytesIO(image_bytes), + length=len(image_bytes), + content_type="image/jpeg", + metadata={"filename": storage_key.split("/")[-1]}, + ) + + +# --------------------------------------------------------------------------- +# Seeders +# --------------------------------------------------------------------------- + +async def seed_staff_users(conn: asyncpg.Connection) -> list[uuid.UUID]: + print(" -> Seeding staff users...") + ids = [] + for u in STAFF_USERS: + row = await conn.fetchrow( + """ + INSERT INTO staff_users (email, password, role, created_at, updated_at) + VALUES ($1, $2, $3::staff_role, $4, $4) + ON CONFLICT (email) DO UPDATE + SET password = EXCLUDED.password, + role = EXCLUDED.role, + updated_at = EXCLUDED.updated_at + RETURNING id + """, + u["email"], hash_password(u["password"]), u["role"], now(), + ) + ids.append(row["id"]) + print(f" [OK] {u['role']}: {u['email']} password: {u['password']}") + return ids + + +async def seed_mobile_users(conn: asyncpg.Connection) -> list[uuid.UUID]: + print(" -> Seeding mobile users...") + ids = [] + for u in MOBILE_USERS: + row = await conn.fetchrow( + """ + INSERT INTO users (email, hashed_password, display_name, created_at, updated_at) + VALUES ($1, $2, $3, $4, $4) + ON CONFLICT (email) DO UPDATE + SET hashed_password = EXCLUDED.hashed_password, + display_name = EXCLUDED.display_name, + updated_at = EXCLUDED.updated_at + RETURNING id + """, + u["email"], hash_password(u["password"]), u["display_name"], now(), + ) + ids.append(row["id"]) + print(f" [OK] {u['display_name']}: {u['email']} password: {u['password']}") + return ids + + +async def seed_devices_and_sessions( + conn: asyncpg.Connection, + user_ids: list[uuid.UUID], +) -> None: + print(" -> Seeding devices + sessions...") + for user_id in user_ids: + device_id = await conn.fetchval( + """ + INSERT INTO user_devices (user_id, device_name, device_type, last_active, created_at) + VALUES ($1, 'Seed Device', 'android', $2, $2) + RETURNING id + """, + user_id, now(), + ) + await conn.execute( + """ + INSERT INTO user_sessions (user_id, device_id, created_at, last_active, expires_at) + VALUES ($1, $2, $3, $3, $4) + ON CONFLICT (user_id, device_id) DO NOTHING + """, + user_id, device_id, now(), future(30), + ) + print(f" [OK] {len(user_ids)} device(s) + session(s)") + + +async def seed_events( + conn: asyncpg.Connection, + staff_ids: list[uuid.UUID], +) -> list[uuid.UUID]: + print(" -> Seeding events...") + ids = [] + for i, e in enumerate(EVENTS): + row = await conn.fetchrow( + """ + INSERT INTO events (name, event_code, event_date, status, created_by, created_at) + VALUES ($1, $2, $3, $4::event_status, $5, $6) + ON CONFLICT (event_code) DO UPDATE + SET name = EXCLUDED.name, + event_date = EXCLUDED.event_date, + status = EXCLUDED.status + RETURNING id + """, + e["name"], e["event_code"], e["event_date"], + e["status"], staff_ids[i % len(staff_ids)], now(), + ) + ids.append(row["id"]) + print(f" [OK] {e['event_code']} — join code: {e['event_code']}") + return ids + + +async def seed_event_participants( + conn: asyncpg.Connection, + event_ids: list[uuid.UUID], + user_ids: list[uuid.UUID], +) -> None: + print(" -> Seeding event participants...") + count = 0 + for event_id in event_ids: + for user_id in user_ids: + await conn.execute( + """ + INSERT INTO event_participants (event_id, user_id, joined_at) + VALUES ($1, $2, $3) + ON CONFLICT (event_id, user_id) DO NOTHING + """, + event_id, user_id, now(), + ) + count += 1 + print(f" [OK] {count} participant record(s)") + + +async def seed_photos( + conn: asyncpg.Connection, + minio: Minio, + event_ids: list[uuid.UUID], + user_ids: list[uuid.UUID], +) -> list[uuid.UUID]: + print(" -> Seeding photos + uploading to MinIO...") + photo_ids = [] + color_index = 0 + for event_id in event_ids: + for i in range(PHOTOS_PER_EVENT): + uploader = user_ids[i % len(user_ids)] + storage_key = make_storage_key(event_id, i + 1) + color = PHOTO_COLORS[color_index % len(PHOTO_COLORS)] + color_index += 1 + + await upload_photo( + minio, storage_key, + f"Event {str(event_id)[:8]} / Photo {i + 1}", + color, + ) + + row = await conn.fetchrow( + """ + INSERT INTO photos + (event_id, uploaded_by, storage_key, taken_at, day_number, + visibility, status, created_at) + VALUES ($1, $2, $3, $4, $5, 'public', 'approved', $6) + RETURNING id + """, + event_id, uploader, storage_key, now(), i + 1, now(), + ) + photo_ids.append(row["id"]) + print(f" [OK] {storage_key}") + return photo_ids + + +async def seed_photo_access( + conn: asyncpg.Connection, + photo_ids: list[uuid.UUID], + user_ids: list[uuid.UUID], +) -> None: + print(" -> Seeding photo access (face matches + approvals)...") + face_count = match_count = approval_count = 0 + + for photo_id in photo_ids: + embedding = [random.uniform(-1.0, 1.0) for _ in range(512)] + embedding_str = "[" + ",".join(str(x) for x in embedding) + "]" + + face_row = await conn.fetchrow( + """ + INSERT INTO photo_faces (photo_id, face_index, embedding, bbox, created_at) + VALUES ($1, 0, $2::vector, $3, $4) + ON CONFLICT (photo_id, face_index) DO NOTHING + RETURNING id + """, + photo_id, embedding_str, + '{"x1":10,"y1":10,"x2":100,"y2":100}', now(), + ) + if face_row: + face_count += 1 + for user_id in user_ids: + await conn.execute( + """ + INSERT INTO face_matches (photo_face_id, user_id, confidence, created_at) + VALUES ($1, $2, $3, $4) + """, + face_row["id"], user_id, + round(random.uniform(0.85, 0.99), 4), now(), + ) + match_count += 1 + + for user_id in user_ids: + await conn.execute( + """ + INSERT INTO photo_approvals (photo_id, user_id, decision, decided_at) + VALUES ($1, $2, 'approved', $3) + """, + photo_id, user_id, now(), + ) + approval_count += 1 + + print(f" [OK] {face_count} face(s), {match_count} match(es), {approval_count} approval(s)") + + +async def seed_user_photos( + conn: asyncpg.Connection, + photo_ids: list[uuid.UUID], + user_ids: list[uuid.UUID], +) -> None: + print(" -> Seeding user_photos...") + count = 0 + for photo_id in photo_ids: + for user_id in user_ids: + await conn.execute( + """ + INSERT INTO user_photos (user_id, photo_id, visibility, created_at) + VALUES ($1, $2, 'public', $3) + ON CONFLICT (user_id, photo_id) DO NOTHING + """, + user_id, photo_id, now(), + ) + count += 1 + print(f" [OK] {count} record(s)") + + +async def seed_processing_jobs( + conn: asyncpg.Connection, + photo_ids: list[uuid.UUID], +) -> None: + print(" -> Seeding processing jobs...") + count = 0 + for photo_id in photo_ids: + for job_type in ["face_detection", "face_embedding"]: + await conn.execute( + """ + INSERT INTO processing_jobs + (photo_id, job_type, status, attempts, created_at, completed_at) + VALUES ($1, $2, $3::processing_job_status, 1, $4, $4) + """, + photo_id, job_type, "completed", now(), + ) + count += 1 + print(f" [OK] {count} job(s)") + + +async def seed_notifications( + conn: asyncpg.Connection, + user_ids: list[uuid.UUID], +) -> None: + print(" -> Seeding notifications...") + for user_id in user_ids: + await conn.execute( + """ + INSERT INTO notifications (user_id, type, payload, created_at) + VALUES ($1, 'welcome', '{"message": "Welcome to multAI!"}', $2) + """, + user_id, now(), + ) + print(f" [OK] {len(user_ids)} notification(s)") + + +async def seed_staff_notifications( + conn: asyncpg.Connection, + staff_ids: list[uuid.UUID], +) -> None: + print(" -> Seeding staff notifications...") + for staff_id in staff_ids: + await conn.execute( + """ + INSERT INTO staff_notifications (staff_user_id, type, payload, created_at) + VALUES ($1, 'system', '{"message": "Staff account seeded."}', $2) + """, + staff_id, now(), + ) + print(f" [OK] {len(staff_ids)} notification(s)") + + +async def seed_upload_request_groups( + conn: asyncpg.Connection, + event_ids: list[uuid.UUID], + staff_ids: list[uuid.UUID], +) -> list[uuid.UUID]: + print(" -> Seeding upload request groups...") + ids = [] + for i, event_id in enumerate(event_ids): + row = await conn.fetchrow( + """ + INSERT INTO upload_request_groups + (event_id, folder_id, requested_by, approved_by, status, + total_photo_count, batch_count, processing_status, created_at, approved_at) + VALUES ($1, $2, $3, $4, 'approved'::upload_request_status, + $5, 2, 'completed', $6, $6) + RETURNING id + """, + event_id, f"gdrive_folder_{i + 1}", + staff_ids[i % len(staff_ids)], + staff_ids[(i + 1) % len(staff_ids)], + PHOTOS_PER_EVENT, now(), + ) + ids.append(row["id"]) + print(f" [OK] {len(ids)} group(s)") + return ids + + +async def seed_upload_requests( + conn: asyncpg.Connection, + event_ids: list[uuid.UUID], + staff_ids: list[uuid.UUID], + group_ids: list[uuid.UUID], +) -> list[uuid.UUID]: + print(" -> Seeding upload requests...") + ids = [] + for i, event_id in enumerate(event_ids): + row = await conn.fetchrow( + """ + INSERT INTO upload_requests + (event_id, drive_file_id, requested_by, approved_by, status, + photo_count, group_id, created_at, approved_at) + VALUES ($1, $2, $3, $4, 'approved'::upload_request_status, $5, $6, $7, $7) + RETURNING id + """, + event_id, f"gdrive_file_{i + 1}", + staff_ids[i % len(staff_ids)], + staff_ids[(i + 1) % len(staff_ids)], + PHOTOS_PER_EVENT, + group_ids[i % len(group_ids)], now(), + ) + ids.append(row["id"]) + print(f" [OK] {len(ids)} request(s)") + return ids + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + +def print_summary() -> None: + print() + print("=" * 55) + print(" SEED COMPLETE - MOBILE TEAM QUICKSTART") + print("=" * 55) + print() + print("Mobile users:") + for u in MOBILE_USERS: + print(f" email: {u['email']}") + print(f" password: {u['password']}") + print() + print("Events:") + for e in EVENTS: + print(f" {e['name']} join code: {e['event_code']}") + print() + print(f"Photos: {len(EVENTS) * PHOTOS_PER_EVENT} total — approved, public, gallery-ready") + print() + print("Staff users:") + for u in STAFF_USERS: + print(f" [{u['role']}] {u['email']} password: {u['password']}") + print("=" * 55) + print() + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +async def main(reset: bool = False) -> None: + dsn = ( + f"postgresql://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}" + f"@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}" + ) + + minio = Minio( + f"{settings.MINIO_HOST}:{settings.MINIO_API_PORT}", + access_key=settings.MINIO_ROOT_USER, + secret_key=settings.MINIO_ROOT_PASSWORD, + secure=False, + ) + + print(f"Connecting to {settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}...") + conn: asyncpg.Connection = await asyncpg.connect(dsn) + + try: + if reset: + await reset_db(conn) + await reset_minio(minio) + + await init_minio(minio) + + async with conn.transaction(): + print("Seeding...\n") + + staff_ids = await seed_staff_users(conn) + user_ids = await seed_mobile_users(conn) + + await seed_devices_and_sessions(conn, user_ids) + + event_ids = await seed_events(conn, staff_ids) + await seed_event_participants(conn, event_ids, user_ids) + + photo_ids = await seed_photos(conn, minio, event_ids, user_ids) + + await seed_photo_access(conn, photo_ids, user_ids) + await seed_user_photos(conn, photo_ids, user_ids) + await seed_processing_jobs(conn, photo_ids) + + await seed_notifications(conn, user_ids) + await seed_staff_notifications(conn, staff_ids) + + group_ids = await seed_upload_request_groups(conn, event_ids, staff_ids) + await seed_upload_requests(conn, event_ids, staff_ids, group_ids) + + print_summary() + + except Exception as e: + print(f"Seed failed: {e}") + raise + finally: + await conn.close() + + +if __name__ == "__main__": + reset_flag = "--reset" in sys.argv + if reset_flag: + print("WARNING: Reset mode — all existing seed data will be wiped.\n") + asyncio.run(main(reset=reset_flag)) diff --git a/pyproject.toml b/pyproject.toml index cb5cd01..2b0f8b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "nats-py>=2.14.0", "passlib[bcrypt]>=1.7.4", "bcrypt==4.3.0", - "psycopg>=3.3.3", + "psycopg[binary]>=3.3.3", "pydantic>=2.12.5", "pydantic-settings>=2.13.1", "pyjwt>=2.11.0", @@ -29,6 +29,9 @@ dependencies = [ "firebase-admin>=6.8.0", "pywebpush>=2.3.0", "opencv-python>=4.13.0.92", + "filetype>=1.2.0", + "pillow-heif>=1.3.0", + "sqlalchemy>=2.0.47", ] [tool.ruff] diff --git a/tests/e2e/test_mobile_auth_intent_e2e.py b/tests/e2e/test_mobile_auth_intent_e2e.py deleted file mode 100644 index 4d56978..0000000 --- a/tests/e2e/test_mobile_auth_intent_e2e.py +++ /dev/null @@ -1,183 +0,0 @@ -import os -import uuid -import requests # type: ignore[import-untyped] -import pytest - - -@pytest.mark.skipif( - not os.getenv("MULTAI_RUN_E2E"), - reason="E2E disabled (set MULTAI_RUN_E2E=1 to run)", -) -class TestMobileAuthEndpointsE2E: - """End-to-end tests for mobile auth register/login endpoints. - - These tests run against a live API. Set MULTAI_RUN_E2E=1 and - MULTAI_E2E_BASE_URL=http://localhost:8000 to enable. - """ - - @pytest.fixture(autouse=True) - def setup(self) -> None: - self.base_url = os.getenv("MULTAI_E2E_BASE_URL", "http://localhost:8000") - self.headers = { - "X-Forwarded-For": f"203.0.113.{uuid.uuid4().int % 250 + 1}", - } - - def test_login_with_unknown_email_fails(self) -> None: - """Test that login with unknown email returns 401.""" - payload = { - "email": f"nonexistent_{uuid.uuid4()}@example.com", - "password": "anypassword", - "device_name": "TestDevice", - "device_type": "android", - "device_id": str(uuid.uuid4()), - } - response = requests.post( - f"{self.base_url}/user/auth/login", - json=payload, - headers=self.headers, - ) - assert response.status_code == 401 - assert "not found" in response.json()["detail"].lower() - - def test_register_with_existing_email_fails(self) -> None: - """Test that registration with existing email returns 409.""" - email = f"testuser_{uuid.uuid4()}@example.com" - device_id = str(uuid.uuid4()) - - # First registration succeeds - register_payload = { - "email": email, - "password": "ValidPass@123", - "device_name": "TestDevice", - "device_type": "android", - "device_id": device_id, - } - response1 = requests.post( - f"{self.base_url}/user/auth/register", - json=register_payload, - headers=self.headers, - ) - assert response1.status_code == 200 - assert response1.json()["is_new_user"] is True - - # Second registration with same email fails - response2 = requests.post( - f"{self.base_url}/user/auth/register", - json=register_payload, - headers=self.headers, - ) - assert response2.status_code == 409 - assert "already" in response2.json()["detail"].lower() - - def test_register_then_login_succeeds(self) -> None: - """Test full flow: register then login.""" - email = f"user_{uuid.uuid4()}@example.com" - password = "ValidPass@123" - device_id = str(uuid.uuid4()) - - # Register - register_payload = { - "email": email, - "password": password, - "device_name": "TestDevice", - "device_type": "android", - "device_id": device_id, - } - register_response = requests.post( - f"{self.base_url}/user/auth/register", - json=register_payload, - headers=self.headers, - ) - assert register_response.status_code == 200 - assert register_response.json()["is_new_user"] is True - register_token = register_response.json()["access_token"] - - # Login with same credentials - login_payload = { - "email": email, - "password": password, - "device_name": "TestDevice", - "device_type": "android", - "device_id": device_id, - } - login_response = requests.post( - f"{self.base_url}/user/auth/login", - json=login_payload, - headers=self.headers, - ) - assert login_response.status_code == 200 - assert login_response.json()["is_new_user"] is False - login_token = login_response.json()["access_token"] - - # Both tokens should work - assert register_token - assert login_token - - def test_login_with_wrong_password_fails(self) -> None: - """Test that login with wrong password returns 401.""" - email = f"user_{uuid.uuid4()}@example.com" - password = "CorrectPass@123" - device_id = str(uuid.uuid4()) - - # Register first - register_payload = { - "email": email, - "password": password, - "device_name": "TestDevice", - "device_type": "android", - "device_id": device_id, - } - register_response = requests.post( - f"{self.base_url}/user/auth/register", - json=register_payload, - headers=self.headers, - ) - assert register_response.status_code == 200 - - # Try to login with wrong password - login_payload = { - "email": email, - "password": "WrongPass@123", - "device_name": "TestDevice", - "device_type": "android", - "device_id": device_id, - } - response = requests.post( - f"{self.base_url}/user/auth/login", - json=login_payload, - headers=self.headers, - ) - assert response.status_code == 401 - assert "invalid" in response.json()["detail"].lower() - - def test_register_requires_password(self) -> None: - """Test that register requires a password.""" - payload = { - "email": "user@example.com", - "device_name": "TestDevice", - "device_type": "android", - "device_id": str(uuid.uuid4()), - # Missing password - } - response = requests.post( - f"{self.base_url}/user/auth/register", - json=payload, - headers=self.headers, - ) - assert response.status_code == 422 - - def test_login_requires_device_type(self) -> None: - """Test that login requires device_type.""" - payload = { - "email": "user@example.com", - "password": "ValidPass@123", - "device_name": "TestDevice", - "device_id": str(uuid.uuid4()), - # Missing device_type - } - response = requests.post( - f"{self.base_url}/user/auth/login", - json=payload, - headers=self.headers, - ) - assert response.status_code == 422 diff --git a/tests/e2e/test_mobile_auth_request_validation_e2e.py b/tests/e2e/test_mobile_auth_request_validation_e2e.py deleted file mode 100644 index 79907c0..0000000 --- a/tests/e2e/test_mobile_auth_request_validation_e2e.py +++ /dev/null @@ -1,55 +0,0 @@ -import os -import uuid - -import httpx -import pytest - - -pytestmark = [ - pytest.mark.e2e, - pytest.mark.skipif( - os.getenv("MULTAI_RUN_E2E") != "1", - reason="set MULTAI_RUN_E2E=1 to run live e2e tests", - ), -] - - -BASE_URL = os.getenv("MULTAI_E2E_BASE_URL", "http://localhost:8000").rstrip("/") -REGISTER_URL = f"{BASE_URL}/user/auth/register" -LOGIN_URL = f"{BASE_URL}/user/auth/login" - - -def _valid_payload() -> dict[str, object]: - return { - "email": f"e2e-{uuid.uuid4()}@example.com", - "password": "ValidPass@123", - "device_name": "Pixel 8", - "device_type": "android", - "device_id": str(uuid.uuid4()), - } - - -@pytest.mark.parametrize("field", ["password", "device_name", "device_type"]) -@pytest.mark.parametrize("value", ["", " "]) -@pytest.mark.parametrize("url", [REGISTER_URL, LOGIN_URL]) -def test_live_register_login_rejects_empty_required_text_fields( - field: str, - value: str, - url: str, -) -> None: - payload = _valid_payload() - payload[field] = value - - response = httpx.post(url, json=payload, timeout=10.0) - - assert response.status_code == 422 - - -@pytest.mark.parametrize("url", [REGISTER_URL, LOGIN_URL]) -def test_live_register_login_rejects_padded_short_password(url: str) -> None: - payload = _valid_payload() - payload["password"] = " a" - - response = httpx.post(url, json=payload, timeout=10.0) - - assert response.status_code == 422 diff --git a/tests/e2e/test_stats_endpoint.py b/tests/e2e/test_stats_endpoint.py new file mode 100644 index 0000000..f16ca7b --- /dev/null +++ b/tests/e2e/test_stats_endpoint.py @@ -0,0 +1,49 @@ +import pytest +import uuid +from fastapi.testclient import TestClient +from datetime import datetime, timezone +from app.main import app +from app.deps.cookie_auth import require_admin_staff +from db.generated.models import StaffUser, StaffRole +from typing import Generator + +@pytest.fixture(scope="module") +def client() -> Generator[TestClient, None, None]: + # Override the dependency to bypass cookie auth + mock_admin = StaffUser( + id=uuid.uuid4(), + email="test_admin@multai.com", + role=StaffRole.ADMIN, + created_at=datetime.now(timezone.utc), + updated_at=datetime.now(timezone.utc), + password="hashed_password" + ) + app.dependency_overrides[require_admin_staff] = lambda: mock_admin + + # Using 'with' triggers the FastAPI lifespan (initializes Redis and DB pools) + with TestClient(app) as c: + yield c + +def test_dashboard_stats(client: TestClient) -> None: + resp = client.get("/admin/stats/dashboard") + assert resp.status_code == 200, f"Expected 200 but got {resp.status_code}: {resp.text}" + data = resp.json() + assert "active_events" in data + +def test_processing_load(client: TestClient) -> None: + resp = client.get("/admin/stats/processing-load") + assert resp.status_code == 200, f"Expected 200 but got {resp.status_code}: {resp.text}" + data = resp.json() + assert "completed" in data + +def test_storage(client: TestClient) -> None: + resp = client.get("/admin/stats/storage") + assert resp.status_code == 200, f"Expected 200 but got {resp.status_code}: {resp.text}" + data = resp.json() + assert "used_bytes" in data + +def test_alerts(client: TestClient) -> None: + resp = client.get("/admin/stats/alerts") + assert resp.status_code == 200, f"Expected 200 but got {resp.status_code}: {resp.text}" + data = resp.json() + assert "alerts" in data diff --git a/tests/unit/test_auth_email_otp.py b/tests/unit/test_auth_email_otp.py new file mode 100644 index 0000000..9a6f825 --- /dev/null +++ b/tests/unit/test_auth_email_otp.py @@ -0,0 +1,171 @@ +import uuid +import json +from unittest.mock import AsyncMock, patch, ANY +import pytest + +from app.service.users import AuthService +from app.schema.request.mobile.auth import MobileRegisterRequest, RegisterVerifyRequest + +@pytest.fixture +def mock_user_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_device_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_session_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_face_embedding_service() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_redis() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def auth_service( + mock_user_querier: AsyncMock, + mock_device_querier: AsyncMock, + mock_session_querier: AsyncMock, + mock_face_embedding_service: AsyncMock, +) -> AuthService: + return AuthService( + user_querier=mock_user_querier, + device_querier=mock_device_querier, + session_querier=mock_session_querier, + face_embedding_service=mock_face_embedding_service, + ) + +@pytest.mark.asyncio +@patch("app.service.users.NatsClient.publish") +async def test_mobile_register_sends_otp( + mock_publish: AsyncMock, + auth_service: AuthService, + mock_redis: AsyncMock, + mock_user_querier: AsyncMock, +) -> None: + # Arrange + req = MobileRegisterRequest( + email="test@example.com", + password="Password1!", + device_name="iPhone", + device_type="iOS", + device_id=uuid.uuid4(), + ) + mock_user_querier.get_user_by_email.return_value = None # User does not exist + mock_redis.incr.return_value = 1 # Rate limit check passes + + # Act + res = await auth_service.mobile_register(redis=mock_redis, req=req) + + # Assert + assert res.status == "pending_verification" + assert res.email == "test@example.com" + + # Verify redis was called to save pending user and OTP + assert mock_redis.set.call_count == 2 + + # Verify NATS publish was called + mock_publish.assert_called_once() + args, _ = mock_publish.call_args + assert args[0] == "email.send_otp" + payload = json.loads(args[1]) + assert payload["email"] == "test@example.com" + assert "otp" in payload + +@pytest.mark.asyncio +async def test_verify_mobile_register_success( + auth_service: AuthService, + mock_redis: AsyncMock, + mock_user_querier: AsyncMock, + mock_device_querier: AsyncMock, + mock_session_querier: AsyncMock, +) -> None: + # Arrange + device_id = uuid.uuid4() + req = RegisterVerifyRequest( + email="test@example.com", + password="Password1!", + device_name="iPhone", + device_type="iOS", + device_id=device_id, + otp="123456" + ) + + mock_redis.get.side_effect = [ + "123456", # First call gets OTP + json.dumps({"hashed_password": "hashed_pass"}) # Second call gets pending user + ] + + mock_user = AsyncMock() + mock_user.id = uuid.uuid4() + mock_user.email = "test@example.com" + mock_user.blocked = False + mock_user_querier.create_user.return_value = mock_user + + mock_session_querier.count_user_sessions.return_value = 0 + mock_session = AsyncMock() + mock_session.id = uuid.uuid4() + mock_session_querier.upsert_session.return_value = mock_session + mock_device_querier.get_device_by_id.return_value = None + + # Act + with patch("app.service.users.SessionService.cache_session_for_auth", new_callable=AsyncMock): + res = await auth_service.verify_mobile_register(redis=mock_redis, req=req) + + # Assert + assert res.is_new_user is True + assert res.user_id == mock_user.id + + # Verify user was created + mock_user_querier.create_user.assert_called_once_with(email="test@example.com", hashed_password="hashed_pass") + + # Verify redis cleanup + assert mock_redis.delete.call_count == 2 + + +@pytest.mark.asyncio +async def test_mobile_register_resend_otp_success( + auth_service: AuthService, + mock_redis: AsyncMock, +) -> None: + # Arrange + email = "test@example.com" + mock_redis.get.return_value = '{"hashed_password": "fake"}' + mock_redis.incr.return_value = 1 + + # Act + with patch("app.infra.nats.NatsClient.publish", new_callable=AsyncMock) as mock_publish: + res = await auth_service.mobile_register_resend_otp(redis=mock_redis, email=email) + + # Assert + assert res.status == "pending_verification" + assert res.message == "New OTP sent to email" + assert res.email == email + + mock_redis.get.assert_called_with(f"pending_user:{email}") + mock_redis.set.assert_called_with(f"otp:{email}", ANY, expire=600) + mock_publish.assert_called_once() + + +@pytest.mark.asyncio +async def test_mobile_register_resend_otp_not_found( + auth_service: AuthService, + mock_redis: AsyncMock, +) -> None: + from fastapi import HTTPException + # Arrange + email = "test@example.com" + mock_redis.incr.return_value = 1 + mock_redis.get.return_value = None # No pending user + + # Act & Assert + with pytest.raises(HTTPException) as exc_info: + await auth_service.mobile_register_resend_otp(redis=mock_redis, email=email) + + assert exc_info.value.status_code == 404 + assert "No pending registration found" in exc_info.value.detail diff --git a/tests/unit/test_bcrypt_truncation.py b/tests/unit/test_bcrypt_truncation.py deleted file mode 100644 index d0e994c..0000000 --- a/tests/unit/test_bcrypt_truncation.py +++ /dev/null @@ -1,20 +0,0 @@ -from app.core.securite import hash_password, verify_password - - -def test_bcrypt_72_byte_truncation_is_prevented() -> None: - # Create two passwords that are longer than 72 bytes and share the same 72-byte prefix - prefix = "a" * 72 - pass1 = prefix + "X" - pass2 = prefix + "Y" - - # Hash both passwords - hash1 = hash_password(pass1) - hash2 = hash_password(pass2) - - # They must produce different hashes (or at least pass2 must not verify with hash1) - assert not verify_password(pass2, hash1) - assert not verify_password(pass1, hash2) - - # Each must verify with its own hash - assert verify_password(pass1, hash1) - assert verify_password(pass2, hash2) diff --git a/tests/unit/test_mobile_auth_email_logging.py b/tests/unit/test_mobile_auth_email_logging.py deleted file mode 100644 index 696e095..0000000 --- a/tests/unit/test_mobile_auth_email_logging.py +++ /dev/null @@ -1,139 +0,0 @@ - -# Test doubles intentionally implement only the AuthService methods exercised here. -# They do not subclass the generated queriers, so mypy would otherwise flag each -# constructor injection as an arg-type mismatch. -# mypy: disable-error-code=arg-type - -import asyncio -import logging -import uuid -from datetime import datetime, timezone - -import pytest - -import app.service.users as users_module -from app.schema.request.mobile.auth import MobileRegisterRequest -from app.service.session import SessionService -from app.service.users import AuthService - - -class FakeUser: - def __init__(self, email: str) -> None: - self.id = uuid.uuid4() - self.email = email - self.blocked = False - self.hashed_password = "hashed" - - -class FakeDevice: - is_invalid_token = False - is_active = True - - -class FakeSession: - def __init__(self) -> None: - self.id = uuid.uuid4() - self.expires_at = datetime.now(timezone.utc) - - -class FakeUserQuerier: - def __init__(self, user: FakeUser) -> None: - self._user = user - - async def get_user_by_email(self, email: str) -> FakeUser | None: - return None - - async def create_user(self, *, email: str, hashed_password: str) -> FakeUser: - self._user.email = email - self._user.hashed_password = hashed_password - return self._user - - -class FakeDeviceQuerier: - async def get_device_by_id(self, id: uuid.UUID) -> FakeDevice | None: - return None - - async def create_device(self, arg: object) -> FakeDevice: - return FakeDevice() - - -class FakeSessionQuerier: - def __init__(self, session: FakeSession) -> None: - self._session = session - - async def count_user_sessions(self, user_id: uuid.UUID) -> int: - return 0 - - async def upsert_session( - self, - *, - user_id: uuid.UUID, - device_id: uuid.UUID, - expires_at: datetime, - ) -> FakeSession: - self._session.expires_at = expires_at - return self._session - - -class FakeRedis: - def __init__(self) -> None: - self._store: dict[str, int] = {} - - async def incr(self, key: str) -> int: - self._store[key] = self._store.get(key, 0) + 1 - return self._store[key] - - async def expire(self, key: str, seconds: int) -> None: - pass - - async def ttl(self, key: str) -> int: - return -1 - - async def set(self, key: str, value: str, expire: int) -> None: - return None - -class FakeFaceEmbeddingService: - pass - - -def test_mobile_register_logs_without_plaintext_email( - caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Verify that plaintext email is never logged; use user_id instead.""" - caplog.set_level(logging.INFO, logger="multAI") - - user = FakeUser(email="user@example.com") - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileRegisterRequest( - email="USER@Example.COM", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - async def _noop_cache_session_for_auth(**_: object) -> None: - return None - - monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) - monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") - monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") - monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) - - asyncio.run(service.mobile_register(FakeRedis(), req)) - - # Verify no plaintext email in logs - assert req.email not in caplog.text - assert "user@example.com" not in caplog.text - # Verify user_id is logged instead - assert "user_id=" in caplog.text - assert "session_id=" in caplog.text - diff --git a/tests/unit/test_mobile_auth_intent_validation.py b/tests/unit/test_mobile_auth_intent_validation.py deleted file mode 100644 index 0f81e97..0000000 --- a/tests/unit/test_mobile_auth_intent_validation.py +++ /dev/null @@ -1,400 +0,0 @@ -from typing import Any - -# Test doubles intentionally implement only the AuthService methods exercised here. -# They do not subclass the generated queriers, so mypy would otherwise flag each -# constructor injection as an arg-type mismatch. -# mypy: disable-error-code=arg-type - -import asyncio -import logging -import uuid -from datetime import datetime, timezone - -import pytest -from fastapi import HTTPException - -import app.service.users as users_module -from app.core.securite import hash_password -from app.schema.request.mobile.auth import MobileLoginRequest, MobileRegisterRequest -from app.service.session import SessionService -from app.service.users import AuthService - - -class FakeUser: - def __init__(self, email: str, exists: bool = True, password: str = "ValidPass@123") -> None: - self.id = uuid.uuid4() - self.email = email - self.blocked = False - self.hashed_password = hash_password(password) - self.exists = exists - - -class FakeDevice: - is_invalid_token = False - is_active = True - - -class FakeSession: - def __init__(self) -> None: - self.id = uuid.uuid4() - self.expires_at = datetime.now(timezone.utc) - - -class FakeUserQuerier: - def __init__(self, user: FakeUser) -> None: - self._user = user - self._created_users: dict[str, FakeUser] = {} - - async def get_user_by_email(self, email: str) -> FakeUser | None: - if self._user.exists and self._user.email == email: - return self._user - if email in self._created_users: - return self._created_users[email] - return None - - async def get_user_by_id(self, id: uuid.UUID) -> FakeUser | None: - if self._user.id == id: - return self._user - return None - - async def create_user(self, *, email: str, hashed_password: str) -> FakeUser: - new_user = FakeUser(email=email, exists=True) - new_user.hashed_password = hashed_password - self._created_users[email] = new_user - return new_user - - -class FakeDeviceQuerier: - async def get_device_by_id(self, id: uuid.UUID) -> FakeDevice | None: - return None - - async def create_device(self, arg: object) -> FakeDevice: - return FakeDevice() - - async def activate_device(self, id: uuid.UUID, user_id: uuid.UUID) -> None: - return None - - -class FakeSessionQuerier: - def __init__(self, session: FakeSession) -> None: - self._session = session - - async def count_user_sessions(self, user_id: uuid.UUID) -> int: - return 0 - - async def get_session_by_id(self, id: uuid.UUID) -> FakeSession | None: - return self._session - - async def upsert_session( - self, - *, - user_id: uuid.UUID, - device_id: uuid.UUID, - expires_at: datetime, - ) -> FakeSession: - self._session.expires_at = expires_at - return self._session - - -class FakeRedis: - def __init__(self) -> None: - self._store: dict[str, int] = {} - - async def incr(self, key: str) -> int: - self._store[key] = self._store.get(key, 0) + 1 - return self._store[key] - - async def expire(self, key: str, seconds: int) -> None: - pass - - async def ttl(self, key: str) -> int: - return -1 - - async def set(self, key: str, value: str, expire: int) -> None: - return None - - -class FakeFaceEmbeddingService: - pass - - -def test_login_with_unknown_email_is_rejected() -> None: - """Test that login with unknown email fails.""" - user = FakeUser(email="user@example.com", exists=True) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileLoginRequest( - email="unknown@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_login(FakeRedis(), req)) - assert exc_info.value.status_code == 401 - assert "not found" in exc_info.value.detail.lower() - - -def test_register_with_existing_email_is_rejected() -> None: - """Test that registration with existing email fails.""" - user = FakeUser(email="user@example.com", exists=True) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileRegisterRequest( - email="user@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_register(FakeRedis(), req)) - assert exc_info.value.status_code == 409 - assert "already" in exc_info.value.detail.lower() - - -def test_login_with_correct_credentials_succeeds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test that login with correct credentials succeeds.""" - user = FakeUser(email="user@example.com", exists=True) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileLoginRequest( - email="user@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - async def _noop_cache_session_for_auth(**_: object) -> None: - return None - - monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) - monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") - monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") - monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) - - result = asyncio.run(service.mobile_login(FakeRedis(), req)) - assert result.access_token == "access" - assert result.refresh_token == "refresh" - assert result.is_new_user is False - - -def test_register_with_new_email_succeeds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test that registration with new email succeeds.""" - user = FakeUser(email="user@example.com", exists=False) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileRegisterRequest( - email="newuser@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - async def _noop_cache_session_for_auth(**_: object) -> None: - return None - - monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) - monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") - monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") - monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) - - result = asyncio.run(service.mobile_register(FakeRedis(), req)) - assert result.access_token == "access" - assert result.refresh_token == "refresh" - assert result.is_new_user is True - - -def test_register_then_login_same_device_succeeds( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test full flow: register then login with same device.""" - user = FakeUser(email="newuser@example.com", exists=False) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - async def _noop_cache_session_for_auth(**_: object) -> None: - return None - - monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) - monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") - monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") - monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) - - device_id = uuid.uuid4() - password = "ValidPass@123" - - # Register - register_req = MobileRegisterRequest( - email="newuser@example.com", - password=password, - device_name="TestDevice", - device_type="android", - device_id=device_id, - ) - result1 = asyncio.run(service.mobile_register(FakeRedis(), register_req)) - assert result1.is_new_user is True - - # Try to register again (should fail) - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_register(FakeRedis(), register_req)) - assert exc_info.value.status_code == 409 - - # Now login - login_req = MobileLoginRequest( - email="newuser@example.com", - password=password, - device_name="TestDevice", - device_type="android", - device_id=device_id, - ) - result2 = asyncio.run(service.mobile_login(FakeRedis(), login_req)) - assert result2.is_new_user is False - - -def test_login_with_wrong_password_fails() -> None: - """Test that login with wrong password fails.""" - user = FakeUser(email="user@example.com", exists=True) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileLoginRequest( - email="user@example.com", - password="wrongpassword", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_login(FakeRedis(), req)) - assert exc_info.value.status_code == 401 - assert "invalid" in exc_info.value.detail.lower() - - -def test_login_logs_correctly( - caplog: pytest.LogCaptureFixture, - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Test that login emits audit-friendly logs without email.""" - caplog.set_level(logging.INFO, logger="multAI") - - user = FakeUser(email="user@example.com", exists=True) - session = FakeSession() - service = AuthService( - user_querier=FakeUserQuerier(user), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileLoginRequest( - email="USER@Example.COM", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - async def _noop_cache_session_for_auth(**_: object) -> None: - return None - - monkeypatch.setattr(SessionService, "cache_session_for_auth", _noop_cache_session_for_auth) - monkeypatch.setattr(users_module, "create_acces_mobile_token", lambda _: "access") - monkeypatch.setattr(users_module, "create_refresh_mobile_token", lambda _: "refresh") - monkeypatch.setattr(users_module, "Get_expiry_time", lambda: 3600) - - asyncio.run(service.mobile_login(FakeRedis(), req)) - - assert "mobile_login attempt" in caplog.text - assert "login success user_id=" in caplog.text - assert "session_id=" in caplog.text - assert "user@example.com" not in caplog.text - - -def test_register_concurrent_signup_integrity_error() -> None: - """Test that concurrent signup IntegrityError is caught and raised as 409.""" - from sqlalchemy.exc import IntegrityError - - class FakeOrigException(Exception): - sqlstate = "23505" - constraint_name = "idx_users_email" - - user = FakeUser(email="user@example.com", exists=False) - session = FakeSession() - - async def _raise_integrity_error(*args: Any, **kwargs: Any) -> Any: - raise IntegrityError( - statement="INSERT INTO users", - params={}, - orig=FakeOrigException("duplicate key value violates unique constraint idx_users_email") - ) - - user_querier = FakeUserQuerier(user) - # Stub create_user to raise IntegrityError - user_querier.create_user = _raise_integrity_error # type: ignore - - service = AuthService( - user_querier=user_querier, - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(session), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - req = MobileRegisterRequest( - email="newuser@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_register(FakeRedis(), req)) - - assert exc_info.value.status_code == 409 - assert "already in use" in exc_info.value.detail.lower() - diff --git a/tests/unit/test_mobile_auth_rate_limiting.py b/tests/unit/test_mobile_auth_rate_limiting.py deleted file mode 100644 index e5f227e..0000000 --- a/tests/unit/test_mobile_auth_rate_limiting.py +++ /dev/null @@ -1,101 +0,0 @@ -import asyncio -import uuid -from typing import Any - -import pytest -from fastapi import HTTPException -from app.service.users import AuthService -from app.schema.request.mobile.auth import MobileLoginRequest - -# Test doubles intentionally implement only the AuthService methods exercised here. -# They do not subclass the generated queriers, so mypy would otherwise flag each -# constructor injection as an arg-type mismatch. -# mypy: disable-error-code=arg-type - - -class MockRedis: - def __init__(self) -> None: - self.data: dict[str, int] = {} - self.ttls: dict[str, int] = {} - - async def incr(self, key: str) -> int: - self.data[key] = self.data.get(key, 0) + 1 - return self.data[key] - - async def expire(self, key: str, seconds: int) -> bool: - self.ttls[key] = seconds - return True - - -class FakeUser: - def __init__(self) -> None: - self.id = uuid.uuid4() - self.email = "test@example.com" - from app.core.securite import hash_password - self.hashed_password = hash_password("ValidPass@123") - self.blocked = False - - -class FakeUserQuerier: - async def get_user_by_email(self, email: str) -> FakeUser: - return FakeUser() - - -class FakeDeviceQuerier: - pass - - -class FakeSessionQuerier: - pass - - -class FakeFaceEmbeddingService: - pass - - -def test_rate_limiting_triggered_after_max_attempts() -> None: - # Set up mocks - redis = MockRedis() - service = AuthService( - user_querier=FakeUserQuerier(), - device_querier=FakeDeviceQuerier(), - session_querier=FakeSessionQuerier(), - face_embedding_service=FakeFaceEmbeddingService(), - ) - - # Stub session creation to avoid database / redis dependencies - async def _dummy_create_session(*args: object, **kwargs: object) -> Any: - from app.schema.response.mobile.auth import MobileAuthResponse - return MobileAuthResponse( - access_token="access", - refresh_token="refresh", - session_id=str(uuid.uuid4()), - expires_in=3600, - user_id=uuid.uuid4(), - is_new_user=False, - ) - service._create_mobile_session = _dummy_create_session # type: ignore - - req = MobileLoginRequest( - email="test@example.com", - password="ValidPass@123", - device_name="Pixel 8", - device_type="android", - device_id=uuid.uuid4(), - ) - - # Call mobile_login 5 times (which is the default max limit in settings) - # The first 5 should succeed without raising exceptions - for i in range(5): - res = asyncio.run(service.mobile_login(redis, req, client_ip="127.0.0.1")) - assert res.access_token == "access" - - # The 6th call must trigger the rate limit and raise 429! - with pytest.raises(HTTPException) as exc_info: - asyncio.run(service.mobile_login(redis, req, client_ip="127.0.0.1")) - assert exc_info.value.status_code == 429 - assert "too many requests" in exc_info.value.detail.lower() - - # The ttls for the keys should have been set - assert redis.ttls["rate:ip:127.0.0.1"] == 60 - assert redis.ttls["rate:email:test@example.com"] == 60 diff --git a/tests/unit/test_mobile_auth_request_validation.py b/tests/unit/test_mobile_auth_request_validation.py deleted file mode 100644 index eb61902..0000000 --- a/tests/unit/test_mobile_auth_request_validation.py +++ /dev/null @@ -1,230 +0,0 @@ -import uuid -from collections.abc import Iterator -from typing import Any - -import pytest -from fastapi.testclient import TestClient - -from app.container import get_container -from app.main import app -from app.schema.request.mobile.auth import MobileLoginRequest, MobileRegisterRequest -from app.schema.response.mobile.auth import MobileAuthResponse - - -class FakeAuthService: - def __init__(self) -> None: - self.register_request: MobileRegisterRequest | None = None - self.login_request: MobileLoginRequest | None = None - self.register_client_ip: object = None - self.login_client_ip: object = None - - async def mobile_register( - self, - redis: object, - req: MobileRegisterRequest, - client_ip: object = None, - ) -> MobileAuthResponse: - self.register_request = req - self.register_client_ip = client_ip - return MobileAuthResponse( - access_token="access", - refresh_token="refresh", - session_id=str(uuid.uuid4()), - expires_in=3600, - user_id=uuid.uuid4(), - is_new_user=True, - ) - - async def mobile_login( - self, - redis: object, - req: MobileLoginRequest, - client_ip: object = None, - ) -> MobileAuthResponse: - self.login_request = req - self.login_client_ip = client_ip - return MobileAuthResponse( - access_token="access", - refresh_token="refresh", - session_id=str(uuid.uuid4()), - expires_in=3600, - user_id=uuid.uuid4(), - is_new_user=False, - ) - - -class FakeAuditService: - async def create_record(self, **kwargs: Any) -> None: - return None - - -class FakeContainer: - def __init__(self) -> None: - self.redis = object() - self.auth_service = FakeAuthService() - self.audit_service = FakeAuditService() - - -@pytest.fixture -def fake_container() -> FakeContainer: - return FakeContainer() - - -@pytest.fixture -def client(fake_container: FakeContainer) -> Iterator[TestClient]: - app.dependency_overrides[get_container] = lambda: fake_container - try: - yield TestClient(app) - finally: - app.dependency_overrides.clear() - - -def _valid_payload() -> dict[str, object]: - return { - "email": "USER@Example.COM", - "password": "ValidPass@123", - "device_name": "Pixel 8", - "device_type": "android", - "device_id": str(uuid.uuid4()), - } - - -@pytest.mark.parametrize("field", ["password", "device_name", "device_type"]) -@pytest.mark.parametrize("value", ["", " "]) -def test_register_login_rejects_empty_required_text_fields( - client: TestClient, - fake_container: FakeContainer, - field: str, - value: str, -) -> None: - for endpoint, attr in ( - ("/user/auth/register", "register_request"), - ("/user/auth/login", "login_request"), - ): - payload = _valid_payload() - payload[field] = value - - response = client.post(endpoint, json=payload) - - assert response.status_code == 422 - assert getattr(fake_container.auth_service, attr) is None - - -def test_register_login_passes_normalized_input_to_service( - client: TestClient, - fake_container: FakeContainer, -) -> None: - for endpoint, attr in ( - ("/user/auth/register", "register_request"), - ("/user/auth/login", "login_request"), - ): - payload = _valid_payload() - payload.update( - { - "email": " USER@Example.COM ", - "device_name": " Pixel 8 ", - "device_type": " ANDROID ", - } - ) - - response = client.post(endpoint, json=payload) - - assert response.status_code == 200 - req = getattr(fake_container.auth_service, attr) - assert req is not None - assert req.email == "user@example.com" - assert req.device_name == "Pixel 8" - assert req.device_type == "android" - - -def test_register_login_password_length_is_checked_after_trimming( - client: TestClient, - fake_container: FakeContainer, -) -> None: - for endpoint, attr in ( - ("/user/auth/register", "register_request"), - ("/user/auth/login", "login_request"), - ): - payload = _valid_payload() - payload["password"] = " a" - - response = client.post(endpoint, json=payload) - - assert response.status_code == 422 - assert getattr(fake_container.auth_service, attr) is None - - -def test_register_login_rejects_oversized_email( - client: TestClient, - fake_container: FakeContainer, -) -> None: - for endpoint, attr in ( - ("/user/auth/register", "register_request"), - ("/user/auth/login", "login_request"), - ): - payload = _valid_payload() - # Create an email address with a length > 255 chars - # username part is 250 'a's, domain is "@example.com" - payload["email"] = "a" * 250 + "@example.com" - - response = client.post(endpoint, json=payload) - - assert response.status_code == 422 - assert getattr(fake_container.auth_service, attr) is None - - -def test_register_enforces_password_complexity( - client: TestClient, - fake_container: FakeContainer, -) -> None: - # Valid complex password - payload = _valid_payload() - payload["password"] = "P@ssword123" - response = client.post("/user/auth/register", json=payload) - assert response.status_code == 200 - assert fake_container.auth_service.register_request is not None - - # Invalid passwords lacking different criteria - invalid_passwords = [ - "p@ssword123", # missing uppercase - "P@SSWORD123", # missing lowercase - "P@sswordabc", # missing digit - "Password123", # missing special char - ] - for pw in invalid_passwords: - fake_container.auth_service.register_request = None - payload = _valid_payload() - payload["password"] = pw - response = client.post("/user/auth/register", json=payload) - assert response.status_code == 422 - assert fake_container.auth_service.register_request is None - - -def test_login_does_not_enforce_password_complexity( - client: TestClient, - fake_container: FakeContainer, -) -> None: - # Simple password should still be allowed to attempt log in - payload = _valid_payload() - payload["password"] = "Password123" # missing special character - response = client.post("/user/auth/login", json=payload) - assert response.status_code == 200 - assert fake_container.auth_service.login_request is not None - - -def test_mobile_auth_uses_forwarded_ip_for_rate_limit_identity( - client: TestClient, - fake_container: FakeContainer, -) -> None: - payload = _valid_payload() - - response = client.post( - "/user/auth/login", - json=payload, - headers={"X-Forwarded-For": "203.0.113.10, 10.0.0.1"}, - ) - - assert response.status_code == 200 - assert fake_container.auth_service.login_client_ip == "203.0.113.10" - - diff --git a/tests/unit/test_photo_approval_lifecycle.py b/tests/unit/test_photo_approval_lifecycle.py new file mode 100644 index 0000000..52bcd68 --- /dev/null +++ b/tests/unit/test_photo_approval_lifecycle.py @@ -0,0 +1,166 @@ +import uuid +from unittest.mock import AsyncMock, MagicMock +import pytest + +from app.worker.photo_worker.main import PhotoWorker +from app.worker.photo_worker.schema.event import PhotoProcessEvent +from app.service.face_embedding import DetectedFace +from app.service.photo_approval import PhotoApprovalService + +@pytest.fixture +def mock_conn() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_face_embedding_service() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_single_face_service() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_notification_service() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_photo_face_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_photo_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_photo_approval_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_processing_job_querier() -> AsyncMock: + return AsyncMock() + +@pytest.fixture +def mock_staged_upload_storage_service() -> AsyncMock: + return AsyncMock() + + +@pytest.mark.asyncio +async def test_group_photo_auto_approve_no_users( + mock_conn: AsyncMock, + mock_face_embedding_service: AsyncMock, + mock_single_face_service: AsyncMock, + mock_notification_service: AsyncMock, + mock_photo_face_querier: AsyncMock, + mock_photo_querier: AsyncMock, + mock_processing_job_querier: AsyncMock, +) -> None: + """ + Test: Group photo with no enrolled users -> photo becomes approved + public. + """ + worker = PhotoWorker( + conn=mock_conn, + face_embedding_service=mock_face_embedding_service, + single_face_service=mock_single_face_service, + user_notification_service=mock_notification_service, + photo_face_querier=mock_photo_face_querier, + photo_querier=mock_photo_querier, + processing_job_querier=mock_processing_job_querier, + ) + + photo_id = uuid.uuid4() + event = PhotoProcessEvent(photo_id=photo_id, image_ref="test.jpg") + + # 2 faces detected + faces = [ + DetectedFace(bbox=(0.0, 0.0, 10.0, 10.0), embedding=[0.1, 0.2]), + DetectedFace(bbox=(10.0, 10.0, 20.0, 20.0), embedding=[0.3, 0.4]), + ] + + # No face matches any user -> insert_photo_face_with_approval returns None + mock_photo_face_querier.insert_photo_face_with_approval.return_value = None + + await worker._handle_group_photo(event, faces) + + # Verify notifications were NOT sent + mock_notification_service.create_notification.assert_not_called() + + # Verify photo is marked public and approved + mock_photo_querier.update_photo_status.assert_called_once_with(id=photo_id, status="approved") + mock_photo_querier.update_photo_visibility.assert_called_once_with(id=photo_id, visibility="public") + + +@pytest.mark.asyncio +async def test_group_photo_pending_with_enrolled_users( + mock_conn: AsyncMock, + mock_face_embedding_service: AsyncMock, + mock_single_face_service: AsyncMock, + mock_notification_service: AsyncMock, + mock_photo_face_querier: AsyncMock, + mock_photo_querier: AsyncMock, + mock_processing_job_querier: AsyncMock, +) -> None: + """ + Test: Group photo with at least one enrolled user -> approval records created, notifications sent, photo stays pending. + """ + worker = PhotoWorker( + conn=mock_conn, + face_embedding_service=mock_face_embedding_service, + single_face_service=mock_single_face_service, + user_notification_service=mock_notification_service, + photo_face_querier=mock_photo_face_querier, + photo_querier=mock_photo_querier, + processing_job_querier=mock_processing_job_querier, + ) + + photo_id = uuid.uuid4() + event = PhotoProcessEvent(photo_id=photo_id, image_ref="test.jpg") + + faces = [ + DetectedFace(bbox=(0.0, 0.0, 10.0, 10.0), embedding=[0.1, 0.2]), + ] + + # Mock DB returning an approval record + mock_approval = MagicMock() + mock_approval.user_id = uuid.uuid4() + mock_approval.photo_id = photo_id + mock_photo_face_querier.insert_photo_face_with_approval.return_value = mock_approval + + await worker._handle_group_photo(event, faces) + + # Verify notification WAS sent + mock_notification_service.create_notification.assert_called_once() + args, kwargs = mock_notification_service.create_notification.call_args + assert kwargs["user_id"] == mock_approval.user_id + + # Verify photo status is NOT updated to approved (stays pending) + mock_photo_querier.update_photo_status.assert_not_called() + mock_photo_querier.update_photo_visibility.assert_not_called() + + +@pytest.mark.asyncio +async def test_expire_stale_marks_photos_approved( + mock_photo_approval_querier: AsyncMock, + mock_photo_querier: AsyncMock, + mock_staged_upload_storage_service: AsyncMock, +) -> None: + """ + Test: After PHOTO_APPROVAL_TIMEOUT_DAYS days, expire_stale marks photos approved + """ + service = PhotoApprovalService( + photo_approval_querier=mock_photo_approval_querier, + photo_querier=mock_photo_querier, + storage_service=mock_staged_upload_storage_service, + ) + + from typing import Any, AsyncIterator + # Mock the generator for expire_stale_approvals + async def mock_generator(*args: Any, **kwargs: Any) -> AsyncIterator[uuid.UUID]: + yield uuid.uuid4() + yield uuid.uuid4() + + mock_photo_approval_querier.expire_stale_approvals = MagicMock(side_effect=mock_generator) + + count = await service.expire_stale(timeout_days=7) + + assert count == 2 + mock_photo_approval_querier.expire_stale_approvals.assert_called_once_with(timeout_days=7) diff --git a/uv.lock b/uv.lock index afaadba..87c2bf7 100644 --- a/uv.lock +++ b/uv.lock @@ -305,6 +305,7 @@ dependencies = [ { name = "bcrypt" }, { name = "cryptography" }, { name = "fastapi", extra = ["standard"] }, + { name = "filetype" }, { name = "firebase-admin" }, { name = "greenlet" }, { name = "insightface" }, @@ -315,7 +316,8 @@ dependencies = [ { name = "opencv-python" }, { name = "opencv-python-headless" }, { name = "passlib", extra = ["bcrypt"] }, - { name = "psycopg" }, + { name = "pillow-heif" }, + { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, @@ -324,6 +326,7 @@ dependencies = [ { name = "pywebpush" }, { name = "redis" }, { name = "setuptools" }, + { name = "sqlalchemy" }, ] [package.dev-dependencies] @@ -342,6 +345,7 @@ requires-dist = [ { name = "bcrypt", specifier = "==4.3.0" }, { name = "cryptography", specifier = ">=46.0.5" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.135.1" }, + { name = "filetype", specifier = ">=1.2.0" }, { name = "firebase-admin", specifier = ">=6.8.0" }, { name = "greenlet", specifier = ">=3.3.2" }, { name = "insightface", specifier = ">=0.7.3" }, @@ -352,7 +356,8 @@ requires-dist = [ { 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" }, + { name = "pillow-heif", specifier = ">=1.3.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "pyjwt", specifier = ">=2.11.0" }, @@ -361,6 +366,7 @@ requires-dist = [ { name = "pywebpush", specifier = ">=2.3.0" }, { name = "redis", specifier = ">=7.2.1" }, { name = "setuptools", specifier = ">=82.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0.47" }, ] [package.metadata.requires-dev] @@ -919,6 +925,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, ] +[[package]] +name = "filetype" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, +] + [[package]] name = "firebase-admin" version = "6.8.0" @@ -2318,6 +2333,45 @@ 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 = "pillow-heif" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pillow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/58/2df4fc42840633e01c97b75965cb1bc6e14425973b92382391650e97e4b7/pillow_heif-1.3.0.tar.gz", hash = "sha256:af8d2bda85e395677d5bb50d7bda3b5655c946cc95b913b5e7222fabacbb467f", size = 17133211, upload-time = "2026-02-27T12:21:36.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f7/e0b13500470421536fdfe01acfc4c56daccd3d23655605aa04cfb30cc58c/pillow_heif-1.3.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:079abbcaeb42ef0849a33f35c1a96ccd431feb56b242a0d4f8435a1c8ca02c7d", size = 4667382, upload-time = "2026-02-27T12:20:45.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/fb/beb62f26231e7c76d235e993d18b4ddb3d2d427e93539b8e6eb8dc188fa7/pillow_heif-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76c33f80ec111492642b98309db98516a7fba9677dcda9ec5fe9111b7e38d720", size = 3392733, upload-time = "2026-02-27T12:20:46.606Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/3d86e237b3a20c909f62a50e8cb3492ed6206675136d1ebddb168920261b/pillow_heif-1.3.0-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33b838d06e2fd730f806af5a76bfc4cd3de9d146d88d37572e40f7a4c4ff8221", size = 5844247, upload-time = "2026-02-27T12:20:49.669Z" }, + { url = "https://files.pythonhosted.org/packages/58/2a/826faf3df8c9ef9a19dc96bdfff34cf76f8b025540d5f931903d2c64f25c/pillow_heif-1.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f92b387af891cf5d98f52e79eeaf51ee7955a54fe2deeec12bfb7519e41464b5", size = 5578692, upload-time = "2026-02-27T12:20:51.219Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4e/59f74fede18e3e06f98a448488df2fa4e5de1c159419d47ca345a51a0da0/pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f9f73246836f93f99343cbc3052b61d212d27e59ddf40262d494a1e3e54af31a", size = 6885928, upload-time = "2026-02-27T12:20:52.934Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e4/bce83d2f4d703f418d252b509a6c9d6de52dc7c4eedcf6a286f871dea824/pillow_heif-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:84c3816742c2e49176e651895e73c555b9c3b0f3561d60230242f3be0c9d272b", size = 6511151, upload-time = "2026-02-27T12:20:54.236Z" }, + { url = "https://files.pythonhosted.org/packages/41/c2/87d433a9681c79e0926d8a113ea153d592ec49d1d7aa7278ee798bc490f2/pillow_heif-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:5f0db0bf49162fb1d73d13340a9576b3a2805bde026a9a40038bcc1a0878d710", size = 5483578, upload-time = "2026-02-27T12:20:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/81/c3/9effa6ab5c2c2ffb80228143c578a9a2a8e2f059dd9d067ec6ff6f6c89db/pillow_heif-1.3.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:641c50a064aa9ad6626a6b2b914b65855202f937d573d53838e344feb2e8c6d1", size = 4667379, upload-time = "2026-02-27T12:20:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/23/eb/b6b52e3655f366b95301f18aecd2d35487cace18d17134b80ad0f70cc1eb/pillow_heif-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9390dd7987887aa09779fbd88bbab715c732c9ad3a71d6707284035e3ca93379", size = 3392725, upload-time = "2026-02-27T12:20:59.52Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b3/b69610e9565fc8bcaf2303f412e857c0439d23cc18cf866c72a96ec6b2e6/pillow_heif-1.3.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e8444ccb330015e1db930207d269886e4b6c666121cd9e5fdad88735950b09f", size = 5844285, upload-time = "2026-02-27T12:21:00.771Z" }, + { url = "https://files.pythonhosted.org/packages/47/8c/be44f6dea425a9756ff418cb03f5ee75ed1c7dd1ff9bee1f3893b2b82da4/pillow_heif-1.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d30054ccc97ecbe5ee3fa486a505ccc33bfbb27f005ad624ddb4c17b80ddd57", size = 5578691, upload-time = "2026-02-27T12:21:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/5c/74/e12d49346a39e2204b408a835b31b2fd9a5d51f97ce3a6015cf22ca09a54/pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dc1b9c9efdf8345d703118449ff69696d0827bdf28e3b52f82015f5714f7c23e", size = 6885923, upload-time = "2026-02-27T12:21:03.782Z" }, + { url = "https://files.pythonhosted.org/packages/80/a6/51c937a9433f5ae9c625b686ee338bdf0080a1661f7eb34daaf75424ee77/pillow_heif-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee26b2155721e7f5f7b10fa93ca2ad3be59547c5c5e5d9d50e6ea17531b81d60", size = 6511216, upload-time = "2026-02-27T12:21:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/63/0a/bb8435e127f75b434166022471bbabf11c8c1fc3d48c8595fd6ab36c2785/pillow_heif-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:17ecbbadfe10ea12a65c1c12354dc1ed8ae1e5d1b7092ea753641b029f7d6f9e", size = 5483570, upload-time = "2026-02-27T12:21:06.566Z" }, + { url = "https://files.pythonhosted.org/packages/3e/17/aa056f8edb71396dd1131abcd0c6feab00097ceec89a12fc62d2dbc3ccf5/pillow_heif-1.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:8267a73d3b2d07a47a96428bd8cd4c406e1637a94f29d4c16ce08b31b8e50a07", size = 4667395, upload-time = "2026-02-27T12:21:08.16Z" }, + { url = "https://files.pythonhosted.org/packages/19/1f/da50ccd271a2878d17df359301dc2f7a79ec1cbb6e92c19ccc8c6219d497/pillow_heif-1.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:36bbea7679467caa3a154db11c04f1ca2fa8591e886f06f40f7831c14b58d771", size = 3392800, upload-time = "2026-02-27T12:21:09.668Z" }, + { url = "https://files.pythonhosted.org/packages/11/bc/1f89d927c1293cf283bc5d0ae6735d268d2de9749aa6fb94342ec838a457/pillow_heif-1.3.0-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea3a4b2de4b6c63407af72afdac901616807c6e6a030fe77851d227bca3727a", size = 5844547, upload-time = "2026-02-27T12:21:10.826Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/d781b23f8bff125c8dd8da63d928a35e38f2b727e89582a1fd323664e968/pillow_heif-1.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05149bd26b08dae5af7a389af6db13cef4f12c7871db73d84e40a1f3c83b0142", size = 5578827, upload-time = "2026-02-27T12:21:12.06Z" }, + { url = "https://files.pythonhosted.org/packages/2a/98/8dcdaafcf9bd8b26ed0569dc93653dc20a06faef7bfbdd4ba05c091c5b60/pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f8b7a50058fc3152f42b68aa2b30601249f61aa5c6c27876af076785c7051fd9", size = 6886088, upload-time = "2026-02-27T12:21:13.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/26/93f3c8bfffb7e8fe0244bf86117235c49c23980e61320e7484c03ac836e2/pillow_heif-1.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:edb3ef437e8841475db14721f0529e600bb55c41b549ad1794a0831e28f33bac", size = 6511291, upload-time = "2026-02-27T12:21:15.354Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f9/a8c72619ec212eb2612730fa2b3068e2d4b59e0a0957c2e8418aa4cff59e/pillow_heif-1.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:bdd6695d5be0d98ae0e9a5f88fe26f1a6eca0a5b6d43d0a92a97f89fea5842f7", size = 5640949, upload-time = "2026-02-27T12:21:16.647Z" }, + { url = "https://files.pythonhosted.org/packages/b7/31/92ce30e1ada892e18a03042bd5a8414f655304a78a36790e657f14265fed/pillow_heif-1.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:65c5d05cb7f5e1eadbe9c605ae3a4dd3ef953adb33e7d809d5fb56f8a6753588", size = 4668365, upload-time = "2026-02-27T12:21:18.004Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2b/789fa3c82063a780e84de667771b8ec30bc328511855f15a83a3c77011ec/pillow_heif-1.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dc177fbdf598770cad4afa99c082a30b9d090e60c39656904338717803ae59b2", size = 3393554, upload-time = "2026-02-27T12:21:19.642Z" }, + { url = "https://files.pythonhosted.org/packages/5b/a4/4f8075f03c1d06d7afd674e263a3f57b7b24130c39b1544555b3b03ed369/pillow_heif-1.3.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71f88d180547bb5112b56310c8c5e338d8358320a402c80afabc6b2f39eadddb", size = 5849609, upload-time = "2026-02-27T12:21:20.953Z" }, + { url = "https://files.pythonhosted.org/packages/0d/08/e33a10bc84ade1b4ec56bdc765735bbfd452513e33537df68107edc0eb86/pillow_heif-1.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9acee893186bdde6140d30a7dc6d7c928e4ad3007989764f6e54a7a517faa332", size = 5582931, upload-time = "2026-02-27T12:21:22.571Z" }, + { url = "https://files.pythonhosted.org/packages/cb/45/6afc0f29701e0c9b911b33a35760ae6e2c581fc49b431dcce22ed18abfba/pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7cf893689132bec18f0c55a505da9ebf3a8feb33dd354fe2ac050f20f4f862e0", size = 6891268, upload-time = "2026-02-27T12:21:24.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0a/0d6a69f76f277692555d0e687dbf3e31d03cf76fffa3ced1fea51a18c481/pillow_heif-1.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:54404c9b6f0323114527579f54cc966b47206f99d943e47d73e1091ab0b9d2ba", size = 6515405, upload-time = "2026-02-27T12:21:25.336Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/716856a36c1cc30a8f1354bf6423f251b1f50851af3e13b9cf084a13d2e3/pillow_heif-1.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:18c7c35a9d98ed9eaaf2db601ee43425ebccc698801df9c008aa04e00756a22e", size = 5641581, upload-time = "2026-02-27T12:21:26.642Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -2463,6 +2517,51 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, ] +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/15/021be5c0cbc5b7c1ab46e91cc3434eb42569f79a0592e67b8d25e66d844d/psycopg_binary-3.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6698dbab5bcef8fdb570fc9d35fd9ac52041771bfcfe6fd0fc5f5c4e36f1e99d", size = 4591170, upload-time = "2026-02-18T16:48:55.594Z" }, + { url = "https://files.pythonhosted.org/packages/f1/54/a60211c346c9a2f8c6b272b5f2bbe21f6e11800ce7f61e99ba75cf8b63e1/psycopg_binary-3.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:329ff393441e75f10b673ae99ab45276887993d49e65f141da20d915c05aafd8", size = 4670009, upload-time = "2026-02-18T16:49:03.608Z" }, + { url = "https://files.pythonhosted.org/packages/c1/53/ac7c18671347c553362aadbf65f92786eef9540676ca24114cc02f5be405/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:eb072949b8ebf4082ae24289a2b0fd724da9adc8f22743409d6fd718ddb379df", size = 5469735, upload-time = "2026-02-18T16:49:10.128Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c3/4f4e040902b82a344eff1c736cde2f2720f127fe939c7e7565706f96dd44/psycopg_binary-3.3.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:263a24f39f26e19ed7fc982d7859a36f17841b05bebad3eb47bb9cd2dd785351", size = 5152919, upload-time = "2026-02-18T16:49:16.335Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/d929679c6a5c212bcf738806c7c89f5b3d0919f2e1685a0e08d6ff877945/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5152d50798c2fa5bd9b68ec68eb68a1b71b95126c1d70adaa1a08cd5eefdc23d", size = 6738785, upload-time = "2026-02-18T16:49:22.687Z" }, + { url = "https://files.pythonhosted.org/packages/69/b0/09703aeb69a9443d232d7b5318d58742e8ca51ff79f90ffe6b88f1db45e7/psycopg_binary-3.3.3-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d6a1e56dd267848edb824dbeb08cf5bac649e02ee0b03ba883ba3f4f0bd54f2", size = 4979008, upload-time = "2026-02-18T16:49:27.313Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a6/e662558b793c6e13a7473b970fee327d635270e41eded3090ef14045a6a5/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73eaaf4bb04709f545606c1db2f65f4000e8a04cdbf3e00d165a23004692093e", size = 4508255, upload-time = "2026-02-18T16:49:31.575Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/0f8b2e1d5e0093921b6f324a948a5c740c1447fbb45e97acaf50241d0f39/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:162e5675efb4704192411eaf8e00d07f7960b679cd3306e7efb120bb8d9456cc", size = 4189166, upload-time = "2026-02-18T16:49:35.801Z" }, + { url = "https://files.pythonhosted.org/packages/92/ec/ce2e91c33bc8d10b00c87e2f6b0fb570641a6a60042d6a9ae35658a3a797/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:fab6b5e37715885c69f5d091f6ff229be71e235f272ebaa35158d5a46fd548a0", size = 3924544, upload-time = "2026-02-18T16:49:41.129Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2f/7718141485f73a924205af60041c392938852aa447a94c8cbd222ff389a1/psycopg_binary-3.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a4aab31bd6d1057f287c96c0effca3a25584eb9cc702f282ecb96ded7814e830", size = 4235297, upload-time = "2026-02-18T16:49:46.726Z" }, + { url = "https://files.pythonhosted.org/packages/57/f9/1add717e2643a003bbde31b1b220172e64fbc0cb09f06429820c9173f7fc/psycopg_binary-3.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:59aa31fe11a0e1d1bcc2ce37ed35fe2ac84cd65bb9036d049b1a1c39064d0f14", size = 3547659, upload-time = "2026-02-18T16:49:52.999Z" }, + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, +] + [[package]] name = "py-vapid" version = "1.9.4"