Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f244a3b
feat: gate mobile endpoints behind face enrollment, fix jsonb round-trip
wailbentafat Aug 24, 2026
4158cb5
Merge pull request #78 from MicroClub-USTHB/feat/onboarding-gate-and-…
wailbentafat Aug 24, 2026
e170851
fix: handle redis unavailability in enrollment lock acquisition
wailbentafat Aug 24, 2026
75bba73
fix: update secure cookie setting based on environment
wailbentafat Aug 25, 2026
59c904c
feat: automate event lifecycle transitions on start/end time
wailbentafat Aug 25, 2026
aeb4d23
Merge pull request #79 from MicroClub-USTHB/feat/automatic-event-life…
wailbentafat Aug 25, 2026
22ff376
feat: expose face_count on photo list endpoints
wailbentafat Aug 25, 2026
53fc8aa
feat: add schema support for direct (non-Drive) bulk uploads
wailbentafat Aug 25, 2026
3e881b8
feat: add SQLC queries for direct upload registration and transfer tr…
wailbentafat Aug 25, 2026
0e7a947
feat: add presigned PUT/stat support; fix Drive-flow params for new s…
wailbentafat Aug 25, 2026
3f06779
feat: add config settings for direct upload
wailbentafat Aug 25, 2026
43e54a5
feat: add direct upload batch registration, confirm, and fail to Uplo…
wailbentafat Aug 25, 2026
7ac3169
feat: add resume and pre-approval transfer guard for direct uploads
wailbentafat Aug 25, 2026
745fd69
feat: add request/response schemas for direct upload endpoints
wailbentafat Aug 25, 2026
b57af9e
feat: add staff router endpoints for direct upload
wailbentafat Aug 25, 2026
1291def
feat: add reconciliation worker for stale direct-upload transfers
wailbentafat Aug 25, 2026
7154235
docs: clarify direct upload router comment
wailbentafat Aug 25, 2026
070d7e3
feat: add Google Drive write capability for syncing approved direct u…
wailbentafat Aug 25, 2026
19b2879
feat: publish Drive sync event for direct-uploaded photos on approval
wailbentafat Aug 25, 2026
6cdc4ee
feat: add drive_sync worker to sync approved direct-upload photos to …
wailbentafat Aug 25, 2026
6ce9277
Merge pull request #80 from MicroClub-USTHB:feat/direct-bulk-upload
wailbentafat Aug 25, 2026
7e780ce
feat: add source and storage_cleaned_at fields to photos, implement a…
wailbentafat Aug 25, 2026
7186410
feat: update photo cleanup logic to be threshold-based and remove imm…
wailbentafat Aug 25, 2026
9017a6d
feat: refactor photo cleanup scheduling to be handled by event lifecy…
wailbentafat Aug 25, 2026
98b7f11
fix: hide unapproved photos from the general user photo gallery
wailbentafat Aug 25, 2026
6d11ed0
feat: add self-service profile (display name) update endpoint
wailbentafat Aug 25, 2026
8076235
refactor(infra): migrate to NATS JetStream and fix tests
ademboukabes Aug 30, 2026
93b3c10
fix(lint): resolve ruff linting and mypy typing errors
ademboukabes Aug 30, 2026
a7bfff4
chore: refactor drive sync and add e2e tests
ademboukabes Aug 30, 2026
aca90a8
feat: robustesse drive sync (redis lock, backoff, ipv4)
ademboukabes Aug 30, 2026
44ae3dc
fix(drive-sync): robustify Redis folder lock (M-1)
ademboukabes Aug 31, 2026
78c4de4
ci: add CD for develop branch
ademboukabes Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- main
- develop
workflow_dispatch:

permissions:
Expand All @@ -21,10 +22,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v4

- name: Set lowercase image name
- name: Set lowercase image name and Docker tag
shell: bash
run: |
echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "DOCKER_TAG=latest" >> "$GITHUB_ENV"
elif [ "${{ github.ref }}" == "refs/heads/develop" ]; then
echo "DOCKER_TAG=develop" >> "$GITHUB_ENV"
else
echo "DOCKER_TAG=${GITHUB_REF_NAME}" >> "$GITHUB_ENV"
fi

- name: Set up QEMU
uses: docker/setup-qemu-action@v3
Expand All @@ -46,5 +54,5 @@ jobs:
push: true
platforms: linux/amd64,linux/arm64
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DOCKER_TAG }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
18 changes: 13 additions & 5 deletions app/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from app.worker.notification.notification_queue import NotificationQueue
from app.worker.notification.settings import NotifSetting


class Container:
def __init__(
self,
Expand All @@ -51,17 +52,23 @@ def __init__(
):
# infrastructure
self.redis = RedisClient.get_instance()
self.face_embedding_service = face_embedding_service or get_face_embedding_service()
self.face_embedding_service = (
face_embedding_service or get_face_embedding_service()
)

# queriers
self.user_querier = user_queries.AsyncQuerier(conn)
self.session_querier = session_queries.AsyncQuerier(conn)
self.device_querier = device_queries.AsyncQuerier(conn)
self.staff_user_querier = staff_user_queries.AsyncQuerier(conn)
self.staff_drive_querier = staff_drive_queries.AsyncQuerier(conn)
self.upload_request_group_querier = upload_request_group_queries.AsyncQuerier(conn)
self.upload_request_group_querier = upload_request_group_queries.AsyncQuerier(
conn
)
self.upload_request_querier = upload_request_queries.AsyncQuerier(conn)
self.upload_request_photo_querier = upload_request_photo_queries.AsyncQuerier(conn)
self.upload_request_photo_querier = upload_request_photo_queries.AsyncQuerier(
conn
)
self.photo_querier = photo_queries.AsyncQuerier(conn)
self.photo_approval_querier = photo_approval_queries.AsyncQuerier(conn)
self.photo_face_querier = photo_face_queries.AsyncQuerier(conn)
Expand All @@ -79,7 +86,6 @@ def __init__(
redis=self.redis,
)


self.device_service = DeviceService()
self.device_service.init(
device_querier=self.device_querier,
Expand Down Expand Up @@ -130,7 +136,8 @@ def __init__(

self.staff_user_service = StaffUserService()
self.staff_user_service.init(
staff_user_querier=self.staff_user_querier,)
staff_user_querier=self.staff_user_querier,
)

self.event_service = EventService(
e_querier=self.event_querier,
Expand All @@ -155,6 +162,7 @@ def __init__(
querier=self.stats_querier,
)


async def get_container(
conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db),
) -> Container:
Expand Down
37 changes: 35 additions & 2 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ class Settings(BaseSettings):
app_name: str = "multAI"
environment: str = "dev"
debug: bool = True
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:5173", "http://127.0.0.1:3000"]
CORS_ORIGINS: list[str] = [
"http://localhost:3000",
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
]

# Redis
REDIS_PORT: int
Expand Down Expand Up @@ -34,6 +39,20 @@ class Settings(BaseSettings):
POSTGRES_PORT: int = 5432

PHOTO_APPROVAL_TIMEOUT_DAYS: int = 7
EVENT_LIFECYCLE_POLL_INTERVAL_SECONDS: int = 60
# How long after an event's end_date approved photos stay in MinIO before
# being cleaned up. Direct-uploaded photos additionally require a
# confirmed Drive sync before cleanup is eligible (see
# ListPhotosDueForStorageCleanup) — MinIO is their only copy until then.
PHOTO_STORAGE_RETENTION_DAYS_AFTER_EVENT_END: int = 20
DIRECT_UPLOAD_PRESIGN_EXPIRES_SECONDS: int = 1800
DIRECT_UPLOAD_STALE_PENDING_MINUTES: int = 45
DIRECT_UPLOAD_RECONCILE_POLL_INTERVAL_SECONDS: int = 300
DIRECT_UPLOAD_MAX_BATCH_SIZE: int = 200
# Dev/testing convenience: when true, a direct-upload group auto-approves
# itself the moment every photo in it has been confirmed uploaded, instead
# of waiting for a team lead to approve manually.
AUTO_APPROVE: bool = True

# Mobile auth/session defaults
MOBILE_SESSION_LIMIT: int = 3
Expand All @@ -54,6 +73,9 @@ class Settings(BaseSettings):
# Rate Limit Settings
RATE_LIMIT_LOGIN_MAX_ATTEMPTS: int = 5
RATE_LIMIT_LOGIN_WINDOW_SECONDS: int = 60
# In dev env, registration OTPs are fixed to this value and the email/NATS
# send is skipped, so mobile devs can verify without a real inbox.
DEV_OTP_BYPASS_CODE: str = "000000"
TRUST_PROXY_HEADERS: bool = True
# Admin list defaults
ADMIN_USERS_DEFAULT_LIMIT: int = 20
Expand All @@ -75,9 +97,20 @@ class Settings(BaseSettings):
GOOGLE_CLIENT_ID: str = ""
GOOGLE_CLIENT_SECRET: str = ""
GOOGLE_REDIRECT_URI: str = ""
# drive.readonly alone can't write; drive.file alone can only see files
# the app itself created, which would break browsing/importing existing
# Drive folders. Both scopes together preserve the existing read/import
# flow and add write access for syncing approved direct uploads back to
# Drive. Existing staff connections keep their old readonly-only grant
# until they disconnect and reconnect through the consent screen.
GOOGLE_OAUTH_SCOPES: str = (
"https://www.googleapis.com/auth/drive.readonly openid email profile"
"https://www.googleapis.com/auth/drive.readonly "
"https://www.googleapis.com/auth/drive.file openid email profile"
)
# Folder ID (from the Drive URL) that approved direct-upload photos get
# synced into. Empty means uploads land in the connected account's Drive
# root instead of a specific folder.
GOOGLE_CLUB_DRIVE_FOLDER_ID: str = ""

FACE_ENCRYPTION_KEY: str
FIREBASE_CREDENTIALS_PATH: str
Expand Down
7 changes: 1 addition & 6 deletions app/core/constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,7 @@ class AuditEventType(str, Enum):
PHOTO_APPROVAL_DECIDED = "photo_approval.decided"


IMAGE_ALLOWED_TYPES = {
"image/jpeg",
"image/png",
"image/heic",
"image/heif"
}
IMAGE_ALLOWED_TYPES = {"image/jpeg", "image/png", "image/heic", "image/heif"}

DEFAULT_CONTENT_TYPE = "application/octet-stream"
DRIVE_ALLOWED_HOSTS = {"drive.google.com", "docs.google.com"}
Expand Down
25 changes: 14 additions & 11 deletions app/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ def bad_request(detail: str = "Bad request") -> HTTPException:
return HTTPException(status_code=400, detail=detail)

@staticmethod
def payement_required(detail:str = "payement required")->HTTPException:
return HTTPException(status_code=402,detail=detail)
def payement_required(detail: str = "payement required") -> HTTPException:
return HTTPException(status_code=402, detail=detail)

@staticmethod
def internal_error(detail: str = "Internal server error") -> HTTPException:
Expand All @@ -51,13 +51,16 @@ def queue_error(detail: str = "Queue operation failed") -> HTTPException:
return HTTPException(status_code=500, detail=detail)

@staticmethod
def image_quality_error(detail: str = "Image does not meet quality requirements") -> HTTPException:
def image_quality_error(
detail: str = "Image does not meet quality requirements",
) -> HTTPException:
return HTTPException(status_code=400, detail=detail)

@staticmethod
def image_format_error(detail: str = "Unsupported image format") -> HTTPException:
return HTTPException(status_code=400, detail=detail)


class DBException(ABC):
"""Abstract class to enforce DB error handling."""

Expand Down Expand Up @@ -115,13 +118,15 @@ def handle_unique_violation(exc: Exception) -> HTTPException:
err_msg = str(exc).lower()
if constraint == "staff_users_email_key" or "staff_users_email_key" in err_msg:
return HTTPException(
status_code=409,
detail="Staff user with this email already exists"
status_code=409, detail="Staff user with this email already exists"
)
if constraint in ("users_email_key", "idx_users_email") or "idx_users_email" in err_msg or "users_email_key" in err_msg:
if (
constraint in ("users_email_key", "idx_users_email")
or "idx_users_email" in err_msg
or "users_email_key" in err_msg
):
return HTTPException(
status_code=409,
detail="Email already in use; please login instead"
status_code=409, detail="Email already in use; please login instead"
)
return HTTPException(status_code=409, detail="Resource already exists")

Expand All @@ -133,6 +138,4 @@ def handle_foreign_key_violation(exc: Exception) -> HTTPException:

@staticmethod
def handle_check_violation(exc: Exception) -> HTTPException:
return HTTPException(
status_code=400, detail="Constraint check failed"
)
return HTTPException(status_code=400, detail="Constraint check failed")
49 changes: 32 additions & 17 deletions app/core/securite.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from app.core.config import settings
from app.core.exceptions import AppException
from app.core.logger import logger

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")


Expand Down Expand Up @@ -42,15 +43,21 @@ def create_acces_mobile_token(session_id: str) -> str:
payload: dict[str, Any] = {
"session_id": session_id,
"exp": int(
(datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time())).timestamp()
(
datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time())
).timestamp()
),
}
return jwt.encode(payload, key=settings.jwt_secret, algorithm=settings.jwt_algorithm)
return jwt.encode(
payload, key=settings.jwt_secret, algorithm=settings.jwt_algorithm
)


def decode_access_mobile_token(token: str) -> dict[str, Any]:
try:
payload = jwt.decode(token, key=settings.jwt_secret, algorithms=[settings.jwt_algorithm])
payload = jwt.decode(
token, key=settings.jwt_secret, algorithms=[settings.jwt_algorithm]
)
return payload
except jwt.ExpiredSignatureError:
raise AppException.unauthorized("Token has expired")
Expand All @@ -65,6 +72,7 @@ def create_raw_refresh_token() -> str:
def hash_refresh_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()


def create_totp_secret() -> str:
return pyotp.random_base32()

Expand All @@ -74,7 +82,9 @@ def get_totp_uri(secret: str, email: str) -> str:
return totp.provisioning_uri(name=email, issuer_name=settings.totp_issuer)


def verify_totp_token_with_window(secret: str, token: str, valid_window: int = 8) -> bool:
def verify_totp_token_with_window(
secret: str, token: str, valid_window: int = 8
) -> bool:
totp = pyotp.TOTP(secret)
return totp.verify(token, valid_window=valid_window)

Expand All @@ -84,15 +94,21 @@ def generate_Acces_token_stuff(user_id: str, role: str) -> str:
"user_id": user_id,
"role": role,
"exp": int(
(datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time())).timestamp()
(
datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time())
).timestamp()
),
}
return jwt.encode(payload, key=settings.jwt_secret, algorithm=settings.jwt_algorithm)
return jwt.encode(
payload, key=settings.jwt_secret, algorithm=settings.jwt_algorithm
)


def _get_refresh_cache_aesgcm() -> AESGCM:
key = base64.b64decode(settings.encryption_key)
return AESGCM(key)


def encrypt_refresh_cache_payload(plaintext: str) -> str:
"""Encrypt a JSON string for storage in Redis. Returns a base64 string
safe to store directly (nonce + ciphertext packed together)."""
Expand All @@ -101,6 +117,7 @@ def encrypt_refresh_cache_payload(plaintext: str) -> str:
ciphertext = aes.encrypt(nonce, plaintext.encode("utf-8"), None)
return base64.b64encode(nonce + ciphertext).decode("utf-8")


def decrypt_refresh_cache_payload(encoded: str) -> str:
"""Reverse of encrypt_refresh_cache_payload. Raises on tampering or
wrong key — treat any exception as 'cache miss'."""
Expand Down Expand Up @@ -152,13 +169,13 @@ def create_access_staff_token(staff_id: str, role: str) -> str:
role=role,
type="access",
exp=int(
(datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time())).timestamp()
(
datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time())
).timestamp()
),
)
return jwt.encode(
payload.model_dump(),
key=settings.jwt_secret,
algorithm=settings.jwt_algorithm
payload.model_dump(), key=settings.jwt_secret, algorithm=settings.jwt_algorithm
)


Expand All @@ -171,13 +188,13 @@ def create_refresh_staff_token(staff_id: str, role: str) -> str:
role=role,
type="refresh",
exp=int(
(datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time() * 4)).timestamp()
(
datetime.now(timezone.utc) + timedelta(seconds=Get_expiry_time() * 4)
).timestamp()
),
)
return jwt.encode(
payload.model_dump(),
key=settings.jwt_secret,
algorithm=settings.jwt_algorithm
payload.model_dump(), key=settings.jwt_secret, algorithm=settings.jwt_algorithm
)


Expand All @@ -187,9 +204,7 @@ def decode_staff_token(token: str) -> StaffJWTPayload:
"""
try:
decoded = jwt.decode(
token,
key=settings.jwt_secret,
algorithms=[settings.jwt_algorithm]
token, key=settings.jwt_secret, algorithms=[settings.jwt_algorithm]
)
return StaffJWTPayload(**decoded)
except jwt.ExpiredSignatureError:
Expand Down
3 changes: 0 additions & 3 deletions app/core/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,16 @@ def check_extension(
if len(filename_splitted) < 2:
raise AppException.bad_request("File should have an extension")


file_ext = filename_splitted[-1]

if file_ext not in allowed_extensions:
raise AppException.bad_request(
f"File extension {file_ext} is not allowed. Allowed extensions are: {', '.join(allowed_extensions)}"
)


if file.content_type not in ext_content_type_map[file_ext]:
raise AppException.bad_request(
f"File content type {file.content_type} does not match extension {file_ext}"
)


return file_ext
Loading
Loading