Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
25e443c
fix(photo-approval): resolve group photo lifecycle gaps
wailbentafat May 31, 2026
a89ee73
test(e2e): add asynchronous AI pipeline E2E test
ademboukabes Jun 1, 2026
33c1ed9
test(ai): add E2E tests, edge cases, load testing, and enable GPU inf…
ademboukabes Jun 2, 2026
6aa4305
Merge develop into fix/photo-approval-lifecycle
ademboukabes Jun 2, 2026
e424ed2
test(photo-approval): add unit tests for group photo lifecycle gaps
ademboukabes Jun 2, 2026
9b3883c
style: fix trailing whitespaces in tests
ademboukabes Jun 2, 2026
ba4b5f2
fix(test): resolve mypy type check errors and unused imports
ademboukabes Jun 2, 2026
bb0d79b
Merge pull request #49 from MicroClub-USTHB/fix/photo-approval-lifecycle
ademboukabes Jun 2, 2026
4e7290c
fix: file type detection
Tyjfre-j Jun 4, 2026
dfe1e73
fix: read image by chunks and exit early on size limit
Tyjfre-j Jun 4, 2026
4426f6b
fix: sanitise filename, use sniffed MIME
Tyjfre-j Jun 4, 2026
d5ef8b3
fix: validate image dimensions to protect ML model from corrupt or ex…
Tyjfre-j Jun 4, 2026
155bff5
fix: add EnrollmentResponse schema
Tyjfre-j Jun 4, 2026
275f04c
fix: correct return type annotation to EnrollmentResponse
Tyjfre-j Jun 4, 2026
f834229
fix: add per-user enrollment rate limit
Tyjfre-j Jun 4, 2026
9c676ab
fix: catch unexpected errors and return clean 500 instead of raw trac…
Tyjfre-j Jun 4, 2026
3018a6a
chore: fix OpenAPI description for enroll endpoint
Tyjfre-j Jun 4, 2026
07e44ef
fix : handle mypy and ruff errors
Tyjfre-j Jun 4, 2026
f8dfb2b
fix: added missing dependencies
Tyjfre-j Jun 4, 2026
e8f84bc
fix(enrollment): add row lock protection
Tyjfre-j Jun 6, 2026
ae0cca6
fix(enrollment): stream validated uploads to embedding
Tyjfre-j Jun 6, 2026
fc2ddbc
fix(enrollment): add audit and lock constants
Tyjfre-j Jun 6, 2026
ea70fe0
fix(enrollment): add redis lock and audit events
Tyjfre-j Jun 6, 2026
7d45354
fix(enrollment): add header prechecks and audit helpers
Tyjfre-j Jun 6, 2026
8e02a8f
Merge pull request #53 from MicroClub-USTHB/fix/enroll
ademboukabes Jun 6, 2026
42365d2
feat(security): implement redis rate limiting on mobile photo endpoint
ademboukabes Jun 6, 2026
1f93320
test(security): fix auth security tests and resolve asyncio loop scop…
ademboukabes Jun 6, 2026
1b205f9
test(e2e): refactor e2e tests and add robust ai pipeline test coverage
ademboukabes Jun 6, 2026
5e05c52
fix(security): resolve test leak in rate limiting tests
ademboukabes Jun 6, 2026
649fd5b
perf(drive): use concurrent asyncio for faster google drive uploads
ademboukabes Jun 6, 2026
5d429fc
fix(core): address critical code review findings
ademboukabes Jun 9, 2026
bdecde7
style(core): apply linter autofixes and mypy type ignores
ademboukabes Jun 9, 2026
c18c8fc
Merge remote-tracking branch 'origin/main' into fix/code-review
ademboukabes Jun 9, 2026
7689b06
chore: remove tests from remote until finalized
ademboukabes Jun 9, 2026
e1b537e
Merge pull request #54 from MicroClub-USTHB/fix/code-review
ademboukabes Jun 9, 2026
fdb26b5
chore: save tests_backup locally
ademboukabes Jun 23, 2026
51910a9
fix(main): await task cancellation on shutdown
ademboukabes Jun 23, 2026
33bf418
perf(drive): limit max imports to 1000 and chunk asyncio tasks
ademboukabes Jun 23, 2026
f322700
feat(auth): Implement async Email OTP Verification for mobile registr…
ademboukabes Jun 24, 2026
2cfe500
fix(auth): add return type for email sender and fix test mock
ademboukabes Jun 24, 2026
381c291
Merge pull request #55 from MicroClub-USTHB/feat/email-otp-verification
ademboukabes Jun 24, 2026
deba687
feat(auth): implement resend-otp route
ademboukabes Jun 24, 2026
c9d0b73
Merge branch 'feat/email-otp-verification' into develop
ademboukabes Jun 24, 2026
06c5543
feat(security): apply robust rate limiting to auth endpoints
ademboukabes Jun 24, 2026
556623f
chore: merge develop into tests-backup
ademboukabes Jun 30, 2026
872de0f
test(core): consolidate test suite, fix E2E auth flow, and update AI …
ademboukabes Jul 1, 2026
ab6418e
style: auto-fix ruff linting errors
ademboukabes Jul 1, 2026
1ee569b
test(integration): fix mock for face embedding stream
ademboukabes Jul 1, 2026
7a00ffa
style: fix remaining manual ruff errors
ademboukabes Jul 1, 2026
f4f7ebd
chore: exclude tests and scripts from mypy
ademboukabes Jul 1, 2026
b93b59d
Merge develop and resolve conflicts
ademboukabes Jul 4, 2026
e6a2a51
Fix PR review bugs and failing tests
ademboukabes Jul 4, 2026
fdca9c8
style: fix ruff whitespace warnings
ademboukabes Jul 4, 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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,9 @@ db.txt

.venv
multiai-c9380-firebase-adminsdk-fbsvc-cb6e5ce41b.json

# Local Test & Debug Files
.coverage
dummy.json
test_output.log
*.log
2 changes: 1 addition & 1 deletion app/router/staff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from app.router.staff.notifications import router as staff_notifications_router
from app.router.staff.uploads import router as staff_uploads_router

router = APIRouter(prefix="/stuff", tags=["stuff"])
router = APIRouter(prefix="/staff", tags=["staff"])
router.include_router(staff_drive_router)
router.include_router(staff_notifications_router)
router.include_router(staff_uploads_router)
4 changes: 1 addition & 3 deletions app/worker/photo_worker/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,7 @@ async def _handle_group_photo(self, event: PhotoProcessEvent, faces: list[Detect
)

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")
logger.info("No users matched in group photo %s, leaving as pending", event.photo_id)


async def _create_job(self, event: PhotoProcessEvent) -> models.ProcessingJob | None:
Expand Down
24 changes: 24 additions & 0 deletions db/generated/photo_approvals.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@
from db.generated import models


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
"""


CREATE_PHOTO_APPROVAL = """-- name: create_photo_approval \\:one
INSERT INTO photo_approvals (
photo_id,
Expand Down Expand Up @@ -68,6 +87,11 @@ class AsyncQuerier:
def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection):
self._conn = conn

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 create_photo_approval(self, *, photo_id: uuid.UUID, user_id: uuid.UUID, decision: str) -> Optional[models.PhotoApproval]:
row = (await self._conn.execute(sqlalchemy.text(CREATE_PHOTO_APPROVAL), {"p1": photo_id, "p2": user_id, "p3": decision})).first()
if row is None:
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,9 @@ warn_redundant_casts = true
follow_imports = "silent"
files = ["app", "db", "migrations"]
exclude = [
"db/generated"
"db/generated",
"tests",
"scripts"
]
[dependency-groups]
dev = [
Expand Down
23 changes: 23 additions & 0 deletions scripts/check_ai_results.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import settings

async def main():
url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
engine = create_async_engine(url)

async with engine.connect() as conn:
import sqlalchemy

# Check processing jobs
jobs = (await conn.execute(sqlalchemy.text("SELECT status, count(*) FROM processing_jobs GROUP BY status"))).fetchall()
print("=== PROCESSING JOBS STATUS ===")
for j in jobs:
print(f"Status: {j[0]}, Count: {j[1]}")

# Check photo faces
faces = (await conn.execute(sqlalchemy.text("SELECT count(*) FROM photo_faces"))).scalar()
print("\n=== VISAGES DETECTES ===")
print(f"Nombre total de visages isolés et enregistrés par l'IA : {faces}")

asyncio.run(main())
17 changes: 17 additions & 0 deletions scripts/check_scopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import settings

async def main():
url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
engine = create_async_engine(url)

async with engine.connect() as conn:
import sqlalchemy
row = (await conn.execute(sqlalchemy.text("SELECT google_email, scopes FROM staff_drive_connections LIMIT 1"))).fetchone()
if row:
print(f"Email: {row[0]}, Scopes: {row[1]}")
else:
print("No connection found")

asyncio.run(main())
44 changes: 44 additions & 0 deletions scripts/generate_drive_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import settings
from db.generated import stuff_user as staff_queries
from app.infra.redis import RedisClient
from app.service.staff_drive import StaffDriveService
from db.generated import staff_drive_connections as drive_queries

async def main():
url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
engine = create_async_engine(url)

# Init redis
RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "")
redis = RedisClient.get_instance()

async with engine.connect() as conn:
q = staff_queries.AsyncQuerier(conn)
import sqlalchemy
row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone()

if not row:
print("No staff user found! Creating one...")
res = await q.create_staff_user(email="testadmin@example.com", hashed_password="pw", display_name="Admin", role="admin")
staff_user_id = res.id
else:
staff_user_id = row[0]

class DummyUser:
pass
staff_user = DummyUser()
staff_user.id = staff_user_id

drive_service = StaffDriveService(
staff_user_querier=q,
drive_connection_querier=drive_queries.AsyncQuerier(conn),
redis=redis
)
url, state = await drive_service.create_connect_url(staff_user)
print("========================")
print("GOOGLE_AUTH_URL:", url)
print("========================")

asyncio.run(main())
50 changes: 50 additions & 0 deletions scripts/list_drive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import settings
from db.generated import stuff_user as staff_queries
from app.infra.redis import RedisClient
from app.infra.google_drive import GoogleDriveClient
from app.service.staff_drive import StaffDriveService
from db.generated import staff_drive_connections as drive_queries

async def main():
url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
engine = create_async_engine(url)

# Init redis
try:
RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "")
except RuntimeError:
pass
redis = RedisClient.get_instance()

async with engine.connect() as conn:
q = staff_queries.AsyncQuerier(conn)
import sqlalchemy
row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone()
staff_user_id = row[0]

class DummyUser:
pass
staff_user = DummyUser()
staff_user.id = staff_user_id

drive_service = StaffDriveService(
staff_user_querier=q,
drive_connection_querier=drive_queries.AsyncQuerier(conn),
redis=redis
)

access_token = await drive_service.get_access_token_for_staff_user(staff_user_id)

# List root folders
print("=== DOSSIERS ET FICHIERS A LA RACINE DU DRIVE ===")
items = await GoogleDriveClient.list_folder_contents(access_token=access_token)
for i in items[:20]:
type_str = "📁 DOSSIER" if i.mime_type == "application/vnd.google-apps.folder" else "📄 FICHIER"
print(f"{type_str} | ID: {i.id} | NOM: {i.name}")

if not items:
print("Aucun fichier trouvé.")

asyncio.run(main())
8 changes: 8 additions & 0 deletions scripts/test_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi.testclient import TestClient
from app.main import app

with TestClient(app) as client:
resp = client.get("/user/photos")
print(f"/user/photos -> {resp.status_code} {resp.text}")
resp2 = client.get("/user/photos/")
print(f"/user/photos/ -> {resp2.status_code} {resp2.text}")
73 changes: 73 additions & 0 deletions scripts/trigger_import.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import settings
from db.generated import stuff_user as staff_queries
from app.infra.redis import RedisClient
from app.infra.google_drive import GoogleDriveClient
from app.infra.minio import init_minio_client
from app.service.staff_drive import StaffDriveService, SelectedDriveFile
from db.generated import staff_drive_connections as drive_queries

async def main():
url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
engine = create_async_engine(url)

try:
RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "")
except RuntimeError:
pass
redis = RedisClient.get_instance()

# Init minio
await init_minio_client(
minio_host=settings.MINIO_HOST,
minio_port=settings.MINIO_API_PORT,
minio_root_user=settings.MINIO_ROOT_USER,
minio_root_password=settings.MINIO_ROOT_PASSWORD
)

async with engine.connect() as conn:
q = staff_queries.AsyncQuerier(conn)
import sqlalchemy
row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone()
staff_user_id = row[0]

class DummyUser:
pass
staff_user = DummyUser()
staff_user.id = staff_user_id

drive_service = StaffDriveService(
staff_user_querier=q,
drive_connection_querier=drive_queries.AsyncQuerier(conn),
redis=redis
)

access_token = await drive_service.get_access_token_for_staff_user(staff_user_id)

print("Fetching images from Drive...")
items = await GoogleDriveClient.list_folder_contents(access_token=access_token)

# Filter images
image_items = [i for i in items if i.mime_type.startswith("image/")]

if not image_items:
print("No images found to import.")
return

print(f"Found {len(image_items)} images. Initiating import...")
selections = [
SelectedDriveFile(id=f.id, name=f.name, mime_type=f.mime_type)
for f in image_items[:20] # Let's import up to 20 for this test
]

results = await drive_service.import_images_from_drive(
staff_user=staff_user,
selected_files=selections,
)

print(f"Successfully imported {len(results)} images to MinIO!")
for r in results:
print(f" -> {r.original_file_name} stored as {r.minio_object_name}")

asyncio.run(main())
80 changes: 80 additions & 0 deletions scripts/trigger_photo_worker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import asyncio
import json
import uuid
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import settings
from app.infra.redis import RedisClient
from app.infra.minio import init_minio_client
from app.infra.nats import NatsClient, NatsSubjects

async def main():
url = f"postgresql+asyncpg://{settings.POSTGRES_USER}:{settings.POSTGRES_PASSWORD}@{settings.POSTGRES_HOST}:{settings.POSTGRES_PORT}/{settings.POSTGRES_DB}"
engine = create_async_engine(url)

try:
RedisClient.init(host=settings.REDIS_HOST, port=settings.REDIS_PORT, password=settings.REDIS_PASSWORD or "")
except RuntimeError:
pass

await init_minio_client(
minio_host=settings.MINIO_HOST,
minio_port=settings.MINIO_API_PORT,
minio_root_user=settings.MINIO_ROOT_USER,
minio_root_password=settings.MINIO_ROOT_PASSWORD
)

await NatsClient.connect(
host=settings.NATS_HOST,
port=settings.NATS_PORT,
user=settings.NATS_USER,
password=settings.NATS_PASSWORD
)

async with engine.connect() as conn:
# staff_queries not used here but kept for context
import sqlalchemy
row = (await conn.execute(sqlalchemy.text("SELECT id FROM staff_users LIMIT 1"))).fetchone()
staff_user_id = row[0]

event_row = (await conn.execute(sqlalchemy.text("SELECT id FROM events LIMIT 1"))).fetchone()
if not event_row:
print("Creating dummy event...")
ev_id = uuid.uuid4()
await conn.execute(sqlalchemy.text("INSERT INTO events (id, title, date, location) VALUES (:id, 'Test Event', now(), 'Test Location')"), {"id": ev_id})
event_id = ev_id
else:
event_id = event_row[0]

from app.infra.minio import ImageBucket
bucket = ImageBucket(f"staff-drive/{staff_user_id}")

objects = bucket.client.list_objects(bucket.bucket_name, prefix=bucket.file_prefix + "/", recursive=True)
count = 0
async for obj in objects:
storage_key = obj.object_name
print(f"Injecting photo {storage_key} into AI pipeline...")

# Create photo in DB
new_id = uuid.uuid4()
await conn.execute(
sqlalchemy.text("INSERT INTO photos (id, event_id, storage_key, visibility) VALUES (:id, :event_id, :storage_key, 'public')"),
{"id": new_id, "event_id": event_id, "storage_key": storage_key}
)

# Publish event
await NatsClient.publish(
NatsSubjects.PHOTO_PROCESS,
json.dumps({
"photo_id": str(new_id),
"image_ref": storage_key,
"event_id": str(event_id)
}).encode("utf-8")
)
count += 1
if count >= 20:
break

await conn.commit()
print(f"Successfully injected {count} photos to the AI worker!")

asyncio.run(main())
Loading