Spaces:
Sleeping
Sleeping
| """ | |
| API Route Definitions β All Endpoints | |
| Base path: /api/v1 | |
| No authentication required β rate limiter protects the API. | |
| Endpoint Naming Convention: | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β Method β Endpoint β Description β | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€ | |
| β GET β /api/v1/health β Health check β | |
| β GET β /api/v1/visits β List all visits β | |
| β GET β /api/v1/visits/{visit_id} β Get visit detail β | |
| β POST β /api/v1/visits β Create new visit β | |
| β PUT β /api/v1/visits/{visit_id} β Update visit β | |
| β DELETE β /api/v1/visits/{visit_id} β Delete visit β | |
| β GET β /api/v1/visits/{visit_id}/checklists β List checklists β | |
| β POST β /api/v1/visits/{visit_id}/checklists β Add checklist item β | |
| β PATCH β /api/v1/checklists/{checklist_id} β Update checklist item β | |
| β DELETE β /api/v1/checklists/{checklist_id} β Delete checklist item β | |
| β GET β /api/v1/visits/{visit_id}/photos β List photos β | |
| β DELETE β /api/v1/photos/{photo_id} β Delete photo β | |
| β GET β /api/v1/analytics/summary β Analytics summary β | |
| β GET β /api/v1/reports/visits/{visit_id}/pdf β Generate visit PDF β | |
| β GET β /api/v1/outlets β List all outlets β | |
| β GET β /api/v1/outlets/{outlet_id} β Outlet detail + scores β | |
| β GET β /api/v1/visits/{visit_id}/assessments β Assessment items by visitβ | |
| β GET β /api/v1/analytics/outlets β Outlet comparison β | |
| β GET β /api/v1/analytics/outlets/{id}/trends β Outlet weekly trends β | |
| β GET β /api/v1/analytics/categories β Category breakdown β | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| Reference: insights/architecture_rule.md | |
| """ | |
| import uuid | |
| from typing import Optional | |
| from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Form | |
| from fastapi.responses import StreamingResponse | |
| from sqlalchemy.ext.asyncio import AsyncSession | |
| import io | |
| from config.database_config import get_db, check_db_health | |
| from controllers.visit_controller import ( | |
| get_all_visits, | |
| get_visit_by_id, | |
| create_new_visit, | |
| update_visit_by_id, | |
| delete_visit_by_id, | |
| get_checklists_by_visit, | |
| create_checklist_item, | |
| update_checklist_item, | |
| delete_checklist_item, | |
| get_photos_by_visit, | |
| delete_photo_by_id, | |
| ) | |
| from controllers.outlet_controller import ( | |
| get_all_outlets, | |
| get_outlet_by_id, | |
| ) | |
| from controllers.assessment_controller import ( | |
| get_assessments_by_visit, | |
| ) | |
| from models.visit_model import VisitCreate, VisitUpdate | |
| from models.checklist_model import ChecklistCreate, ChecklistUpdate | |
| from services.analytics_service import analytics_service | |
| from services.pdf_service import pdf_service | |
| from services.gs_analytics_service import gs_analytics_service | |
| # ββ Main Router ββ | |
| router = APIRouter(prefix="/api/v1") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # HEALTH CHECK | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def health_check(): | |
| """ | |
| Returns the health status of the API and database connection. | |
| Use this endpoint to verify the service is running and can reach PostgreSQL. | |
| """ | |
| db_status = await check_db_health() | |
| return { | |
| "status": "success", | |
| "data": { | |
| "api": "running", | |
| **db_status, | |
| }, | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # VISITS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def list_all_visits( | |
| page: int = Query(1, ge=1, description="Page number"), | |
| per_page: int = Query(20, ge=1, le=100, description="Items per page"), | |
| status: Optional[str] = Query(None, description="Filter by status (pending, completed, etc.)"), | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Retrieve a paginated list of all visits. | |
| - **page**: Page number (default: 1) | |
| - **per_page**: Items per page (default: 20, max: 100) | |
| - **status**: Optional filter by visit status | |
| """ | |
| return await get_all_visits(db, page=page, per_page=per_page, status=status) | |
| async def get_single_visit( | |
| visit_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Retrieve a single visit by its UUID, including related checklists and photos. | |
| """ | |
| result = await get_visit_by_id(db, visit_id) | |
| if not result: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "code": "NOT_FOUND", | |
| "message": f"Visit with ID '{visit_id}' not found", | |
| }, | |
| ) | |
| return result | |
| async def create_visit( | |
| visit_data: VisitCreate, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Create a new visit record. | |
| Required fields: | |
| - **title**: Visit title (1-255 chars) | |
| - **visit_date**: Scheduled date and time (ISO 8601) | |
| Optional fields: | |
| - **description**: Additional details | |
| - **location**: Visit location | |
| - **status**: Initial status (default: 'pending') | |
| """ | |
| return await create_new_visit(db, visit_data) | |
| async def update_visit( | |
| visit_id: uuid.UUID, | |
| update_data: VisitUpdate, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Update an existing visit. Only provided fields will be changed. | |
| """ | |
| result = await update_visit_by_id(db, visit_id, update_data) | |
| if not result: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "code": "NOT_FOUND", | |
| "message": f"Visit with ID '{visit_id}' not found", | |
| }, | |
| ) | |
| return result | |
| async def delete_visit( | |
| visit_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Delete a visit by its UUID. This cascades to delete all related | |
| checklists and photos. | |
| """ | |
| deleted = await delete_visit_by_id(db, visit_id) | |
| if not deleted: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "code": "NOT_FOUND", | |
| "message": f"Visit with ID '{visit_id}' not found", | |
| }, | |
| ) | |
| return { | |
| "status": "success", | |
| "message": f"Visit '{visit_id}' deleted successfully", | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CHECKLISTS (nested under visits) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def list_visit_checklists( | |
| visit_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Retrieve all checklist items for a specific visit. | |
| """ | |
| result = await get_checklists_by_visit(db, visit_id) | |
| if result is None: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "code": "NOT_FOUND", | |
| "message": f"Visit with ID '{visit_id}' not found", | |
| }, | |
| ) | |
| return result | |
| async def add_checklist_item( | |
| visit_id: uuid.UUID, | |
| item_data: ChecklistCreate, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Add a new checklist item to a visit. | |
| Required fields: | |
| - **item_name**: Name of the checklist item (1-255 chars) | |
| Optional fields: | |
| - **is_checked**: Whether the item starts as checked (default: false) | |
| - **notes**: Additional notes | |
| """ | |
| result = await create_checklist_item(db, visit_id, item_data) | |
| if result is None: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "code": "NOT_FOUND", | |
| "message": f"Visit with ID '{visit_id}' not found", | |
| }, | |
| ) | |
| return result | |
| async def patch_checklist_item( | |
| checklist_id: uuid.UUID, | |
| update_data: ChecklistUpdate, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Update a checklist item. Only provided fields will be changed. | |
| Setting is_checked to true will auto-set the checked_at timestamp. | |
| """ | |
| result = await update_checklist_item(db, checklist_id, update_data) | |
| if result is None: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "code": "NOT_FOUND", | |
| "message": f"Checklist item with ID '{checklist_id}' not found", | |
| }, | |
| ) | |
| return result | |
| async def remove_checklist_item( | |
| checklist_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Delete a checklist item by its UUID.""" | |
| deleted = await delete_checklist_item(db, checklist_id) | |
| if not deleted: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "code": "NOT_FOUND", | |
| "message": f"Checklist item with ID '{checklist_id}' not found", | |
| }, | |
| ) | |
| return { | |
| "status": "success", | |
| "message": f"Checklist item '{checklist_id}' deleted successfully", | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PHOTOS (nested under visits) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def list_visit_photos( | |
| visit_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Retrieve all photos attached to a specific visit. | |
| """ | |
| result = await get_photos_by_visit(db, visit_id) | |
| if result is None: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "code": "NOT_FOUND", | |
| "message": f"Visit with ID '{visit_id}' not found", | |
| }, | |
| ) | |
| return result | |
| async def remove_photo( | |
| photo_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Delete a photo record by its UUID.""" | |
| deleted = await delete_photo_by_id(db, photo_id) | |
| if not deleted: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={ | |
| "code": "NOT_FOUND", | |
| "message": f"Photo with ID '{photo_id}' not found", | |
| }, | |
| ) | |
| return { | |
| "status": "success", | |
| "message": f"Photo '{photo_id}' deleted successfully", | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ANALYTICS & REPORTS (Phase 5) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def get_analytics_summary(db: AsyncSession = Depends(get_db)): | |
| """ | |
| Overall analytics across all visits: | |
| - Total visits broken down by status | |
| - Checklist completion rate across all items | |
| - Total photo count | |
| """ | |
| return await analytics_service.get_summary(db) | |
| async def get_single_visit_analytics( | |
| visit_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Analytics breakdown for a specific visit.""" | |
| result = await analytics_service.get_visit_analytics(db, visit_id) | |
| if result is None: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={"code": "NOT_FOUND", "message": f"Visit '{visit_id}' not found"}, | |
| ) | |
| return result | |
| async def get_visit_trends( | |
| days: int = Query(30, ge=1, le=365, description="Number of days to look back"), | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Returns daily visit counts for the last [days] days (default: 30).""" | |
| return await analytics_service.get_visit_trends(db, days=days) | |
| async def generate_visit_pdf( | |
| visit_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Generate and download a PDF report for a specific visit. | |
| The PDF includes: | |
| - Visit details (title, location, date, status) | |
| - Full checklist with check status and notes | |
| - Photo metadata table | |
| - Summary statistics | |
| Returns a downloadable PDF file. | |
| """ | |
| pdf_bytes = await pdf_service.generate_visit_report(db, visit_id) | |
| if pdf_bytes is None: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={"code": "NOT_FOUND", "message": f"Visit '{visit_id}' not found"}, | |
| ) | |
| return StreamingResponse( | |
| io.BytesIO(pdf_bytes), | |
| media_type="application/pdf", | |
| headers={ | |
| "Content-Disposition": f'attachment; filename="visit_report_{visit_id}.pdf"', | |
| "Content-Length": str(len(pdf_bytes)), | |
| }, | |
| ) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # OUTLETS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def list_outlets(db: AsyncSession = Depends(get_db)): | |
| """Retrieve all outlets sorted by average assessment score.""" | |
| return await get_all_outlets(db) | |
| async def get_outlet_detail( | |
| outlet_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Get a single outlet with: | |
| - Visit history (dates, scores, ghost shopper) | |
| - Category breakdown (AβE average scores) | |
| """ | |
| result = await get_outlet_by_id(db, outlet_id) | |
| if not result: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={"code": "NOT_FOUND", "message": f"Outlet '{outlet_id}' not found"}, | |
| ) | |
| return result | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # ASSESSMENTS (nested under visits) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def list_visit_assessments( | |
| visit_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Retrieve all Ghost Shopper assessment items for a visit, | |
| grouped by category (AβE) with per-category and overall averages. | |
| """ | |
| result = await get_assessments_by_visit(db, visit_id) | |
| if result is None: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={"code": "NOT_FOUND", "message": f"Visit '{visit_id}' not found"}, | |
| ) | |
| return result | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GHOST SHOPPER ANALYTICS | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def compare_outlets(db: AsyncSession = Depends(get_db)): | |
| """ | |
| Compare all outlets side by side: | |
| - Overall average score per outlet | |
| - Per-category score matrix (outlet Γ category) | |
| """ | |
| return await gs_analytics_service.get_outlet_comparison(db) | |
| async def get_outlet_trends( | |
| outlet_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """Returns chronological visit scores and per-category trends for an outlet.""" | |
| result = await gs_analytics_service.get_outlet_trends(db, outlet_id) | |
| if result is None: | |
| raise HTTPException( | |
| status_code=404, | |
| detail={"code": "NOT_FOUND", "message": f"Outlet '{outlet_id}' not found"}, | |
| ) | |
| return result | |
| async def get_category_breakdown(db: AsyncSession = Depends(get_db)): | |
| """ | |
| Global category analysis across all outlets: | |
| - Average score per category (AβE) | |
| - Min/max scores | |
| - Total item count | |
| """ | |
| return await gs_analytics_service.get_category_summary(db) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # GHOST SHOPPER VISIT CREATION & ASSESSMENT MANAGEMENT | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async def create_gs_visit( | |
| visit: VisitCreate, | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| # Create the base visit | |
| created_visit = await create_new_visit(db, visit) | |
| visit_id = created_visit["data"].id | |
| # 21 Default Items | |
| items = [ | |
| ("A", 1, "Disambut saat pertama kali sampai dikasir"), | |
| ("A", 2, "Staf ramah & menyapa dengan sopan"), | |
| ("A", 3, "Staf memakai seragam lengkap dan rapi"), | |
| ("A", 4, "Staf menawarkan rekomendasi menu"), | |
| ("A", 5, "Proses pemesanan berjalan lancar"), | |
| ("B", 1, "Makanan dan minuman disajikan sesuai pesanan"), | |
| ("B", 2, "Rasa minuman dan makanan enak & konsisten"), | |
| ("B", 3, "Suhu minuman sesuai jenis (dingin/panas)"), | |
| ("B", 4, "Penampilan produk menarik (visual)"), | |
| ("B", 5, "Waktu tunggu makanan dan minuman wajar (<7 - 15 menit)"), | |
| ("C", 1, "Area kasir dan bar bersih"), | |
| ("C", 2, "Meja dan kursi bersih & rapi"), | |
| ("C", 3, "Lantai dan tempat sampah terjaga"), | |
| ("C", 4, "Musik & suasana mendukung kenyamanan"), | |
| ("D", 1, "Tidak ada staf merokok di area kerja"), | |
| ("D", 2, "Peralatan bersih dan tertata"), | |
| ("E", 1, "Pelayanan"), | |
| ("E", 2, "Produk"), | |
| ("E", 3, "Kebersihan"), | |
| ("E", 4, "Kenyamanan"), | |
| ("E", 5, "Keseluruhan Pengalaman") | |
| ] | |
| from sqlalchemy import text | |
| for cat, num, crit in items: | |
| await db.execute( | |
| text(""" | |
| INSERT INTO assessments (id, visit_id, category, item_no, criteria, score, raw_value) | |
| VALUES (:id, :vid, :cat, :num, :crit, 0.0, 'Belum dinilai') | |
| """), | |
| { | |
| "id": uuid.uuid4(), | |
| "vid": visit_id, | |
| "cat": cat, | |
| "num": num, | |
| "crit": crit | |
| } | |
| ) | |
| await db.commit() | |
| return {"status": "success", "message": "GS Visit created with 21 items", "data": created_visit["data"].model_dump()} | |
| from controllers.assessment_controller import update_assessment, upload_assessment_photo, get_assessment_photos | |
| async def update_assessment_route( | |
| assessment_id: uuid.UUID, | |
| update_data: dict, | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| result = await update_assessment(db, assessment_id, update_data) | |
| if not result: | |
| raise HTTPException(status_code=404, detail="Assessment not found") | |
| return result | |
| async def upload_assessment_photo_route( | |
| assessment_id: uuid.UUID, | |
| file: UploadFile = File(...), | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| result = await upload_assessment_photo(db, assessment_id, file) | |
| if not result: | |
| raise HTTPException(status_code=404, detail="Assessment not found") | |
| return result | |
| async def get_assessment_photos_route( | |
| assessment_id: uuid.UUID, | |
| db: AsyncSession = Depends(get_db) | |
| ): | |
| result = await get_assessment_photos(db, assessment_id) | |
| if result is None: | |
| raise HTTPException(status_code=404, detail="Assessment not found") | |
| return {"status": "success", "data": result} | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PRESIGNED URL β DIRECT UPLOAD ARCHITECTURE | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Architecture Reference: insights/architecture_rule.md | |
| # "Direct Upload: API hanya memberikan Presigned URL, lalu | |
| # Flutter mengunggah foto langsung ke S3/MinIO." | |
| # | |
| # Flow: | |
| # 1. Client β GET /photos/presigned-url?ext=jpg&visit_id=... | |
| # 2. Client β PUT <presigned_url> (binary upload to S3/MinIO) | |
| # 3. Client β POST /photos/confirm (store URL in database) | |
| async def get_presigned_url( | |
| ext: str = Query("jpg", description="File extension: jpg, jpeg, png"), | |
| visit_id: Optional[str] = Query(None, description="Associated visit UUID"), | |
| assessment_id: Optional[str] = Query(None, description="Associated assessment UUID"), | |
| ): | |
| """ | |
| Generates a short-lived presigned PUT URL for direct upload to S3/GCS/MinIO. | |
| The client uploads the photo binary directly to the returned `upload_url`, | |
| bypassing FastAPI entirely. After upload, call POST /photos/confirm. | |
| Returns: | |
| upload_url: The presigned PUT URL (expires in 5 minutes) | |
| object_key: The storage object key | |
| storage_url: The permanent URL after upload completes | |
| """ | |
| try: | |
| from services.storage_service import storage_service | |
| result = await storage_service.generate_presigned_upload_url( | |
| file_extension=ext, | |
| visit_id=visit_id, | |
| assessment_id=assessment_id, | |
| ) | |
| return {"status": "success", "data": result} | |
| except ImportError: | |
| raise HTTPException( | |
| status_code=501, | |
| detail={ | |
| "code": "STORAGE_NOT_CONFIGURED", | |
| "message": ( | |
| "Cloud storage backend not configured. " | |
| "Install boto3 (pip install boto3) and set STORAGE_* env vars. " | |
| "See services/storage_service.py for details." | |
| ), | |
| }, | |
| ) | |
| except Exception as e: | |
| raise HTTPException( | |
| status_code=500, | |
| detail={ | |
| "code": "PRESIGN_ERROR", | |
| "message": f"Failed to generate presigned URL: {e}", | |
| }, | |
| ) | |
| async def confirm_photo_upload( | |
| body: dict, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| After the client uploads a photo directly to S3/MinIO via the presigned URL, | |
| it calls this endpoint to record the permanent storage_url in the database. | |
| Required body fields: | |
| storage_url: str β The permanent URL from presigned-url response | |
| visit_id: str β Visit UUID | |
| file_name: str β Original file name | |
| Optional body fields: | |
| assessment_id: str β Assessment UUID (for per-item gallery) | |
| file_size: int β File size in bytes | |
| caption: str β Photo caption | |
| """ | |
| storage_url = body.get("storage_url") | |
| visit_id_str = body.get("visit_id") | |
| file_name = body.get("file_name") | |
| if not all([storage_url, visit_id_str, file_name]): | |
| raise HTTPException( | |
| status_code=422, | |
| detail={ | |
| "code": "VALIDATION_ERROR", | |
| "message": "storage_url, visit_id, and file_name are required", | |
| }, | |
| ) | |
| from sqlalchemy import text as sa_text | |
| from datetime import datetime | |
| # Verify visit exists | |
| result = await db.execute( | |
| sa_text("SELECT id FROM visits WHERE id = :vid"), | |
| {"vid": uuid.UUID(visit_id_str)}, | |
| ) | |
| if not result.fetchone(): | |
| raise HTTPException(status_code=404, detail={"code": "NOT_FOUND", "message": "Visit not found"}) | |
| photo_id = uuid.uuid4() | |
| assessment_id_val = body.get("assessment_id") | |
| await db.execute( | |
| sa_text(""" | |
| INSERT INTO photos (id, visit_id, assessment_id, file_path, file_name, file_size, mime_type, uploaded_at) | |
| VALUES (:id, :vid, :aid, :path, :name, :size, :mime, :now) | |
| """), | |
| { | |
| "id": photo_id, | |
| "vid": uuid.UUID(visit_id_str), | |
| "aid": uuid.UUID(assessment_id_val) if assessment_id_val else None, | |
| "path": storage_url, | |
| "name": file_name, | |
| "size": body.get("file_size"), | |
| "mime": f"image/{file_name.rsplit('.', 1)[-1]}" if "." in file_name else "image/jpeg", | |
| "now": datetime.utcnow(), | |
| }, | |
| ) | |
| await db.commit() | |
| return { | |
| "status": "success", | |
| "data": {"id": str(photo_id), "storage_url": storage_url}, | |
| "message": "Photo upload confirmed and saved to database", | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # JWT AUTHENTICATION | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Architecture Reference: insights/architecture_rule.md | |
| # Phase 2: "Implement JWT Authentication and RBAC in auth_controller.py" | |
| # "POST /api/v1/auth/login β Get JWT & set FCM token" | |
| # | |
| # Dependencies: pip install pyjwt[crypto] passlib[bcrypt] | |
| # These endpoints return 501 until the deps are installed. | |
| async def register_route( | |
| body: dict, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Create a new user account with email, password, full_name, and role. | |
| Required body: | |
| email: str, password: str (min 8 chars), full_name: str | |
| Optional body: | |
| role: str β 'admin', 'inspector' (default), or 'viewer' | |
| """ | |
| try: | |
| from controllers.auth_controller import register_user | |
| email = body.get("email") | |
| password = body.get("password") | |
| full_name = body.get("full_name") | |
| if not all([email, password, full_name]): | |
| raise HTTPException( | |
| status_code=422, | |
| detail={"code": "VALIDATION_ERROR", "message": "email, password, and full_name are required"}, | |
| ) | |
| if len(password) < 8: | |
| raise HTTPException( | |
| status_code=422, | |
| detail={"code": "VALIDATION_ERROR", "message": "Password must be at least 8 characters"}, | |
| ) | |
| result = await register_user( | |
| db, email=email, password=password, | |
| full_name=full_name, role=body.get("role", "inspector"), | |
| ) | |
| return result | |
| except ImportError as e: | |
| raise HTTPException(status_code=501, detail={"code": "AUTH_NOT_CONFIGURED", "message": str(e)}) | |
| except ValueError as e: | |
| raise HTTPException(status_code=409, detail={"code": "ALREADY_EXISTS", "message": str(e)}) | |
| async def login_route( | |
| body: dict, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Authenticate with email + password. Returns JWT access token. | |
| Required body: | |
| email: str, password: str | |
| Optional body: | |
| fcm_token: str β Firebase Cloud Messaging device token | |
| """ | |
| try: | |
| from controllers.auth_controller import authenticate_user | |
| email = body.get("email") | |
| password = body.get("password") | |
| if not all([email, password]): | |
| raise HTTPException( | |
| status_code=422, | |
| detail={"code": "VALIDATION_ERROR", "message": "email and password are required"}, | |
| ) | |
| result = await authenticate_user( | |
| db, email=email, password=password, | |
| fcm_token=body.get("fcm_token"), | |
| ) | |
| return result | |
| except ImportError as e: | |
| raise HTTPException(status_code=501, detail={"code": "AUTH_NOT_CONFIGURED", "message": str(e)}) | |
| except ValueError as e: | |
| raise HTTPException(status_code=401, detail={"code": "UNAUTHORIZED", "message": str(e)}) | |
| async def me_route( | |
| authorization: Optional[str] = None, | |
| db: AsyncSession = Depends(get_db), | |
| ): | |
| """ | |
| Returns the authenticated user's profile. | |
| Requires header: Authorization: Bearer <token> | |
| Note: Currently reads from query/header β when auth middleware is wired, | |
| this will use the injected current_user dependency. | |
| """ | |
| try: | |
| from controllers.auth_controller import verify_token, get_user_by_id | |
| if not authorization or not authorization.startswith("Bearer "): | |
| raise HTTPException( | |
| status_code=401, | |
| detail={"code": "UNAUTHORIZED", "message": "Authorization header required: Bearer <token>"}, | |
| ) | |
| token = authorization.split(" ", 1)[1] | |
| payload = await verify_token(token) | |
| user = await get_user_by_id(db, payload["user_id"]) | |
| if not user: | |
| raise HTTPException(status_code=404, detail={"code": "NOT_FOUND", "message": "User not found"}) | |
| return {"status": "success", "data": user} | |
| except ImportError as e: | |
| raise HTTPException(status_code=501, detail={"code": "AUTH_NOT_CONFIGURED", "message": str(e)}) | |
| except ValueError as e: | |
| raise HTTPException(status_code=401, detail={"code": "UNAUTHORIZED", "message": str(e)}) | |