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"