azzaraqi
Deploy FastAPI backend with Supabase pooler support
b84ea83
Raw
History Blame Contribute Delete
34.6 kB
"""
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
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@router.get(
"/health",
tags=["Health"],
summary="Health check β€” verify API and database connectivity",
)
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
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@router.get(
"/visits",
tags=["Visits"],
summary="List all visits β€” paginated with optional status filter",
)
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)
@router.get(
"/visits/{visit_id}",
tags=["Visits"],
summary="Get visit detail β€” includes checklists and photos",
)
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
@router.post(
"/visits",
tags=["Visits"],
status_code=201,
summary="Create new visit β€” provide title, date, and optional details",
)
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)
@router.put(
"/visits/{visit_id}",
tags=["Visits"],
summary="Update visit β€” modify title, date, status, or other fields",
)
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
@router.delete(
"/visits/{visit_id}",
tags=["Visits"],
summary="Delete visit β€” also removes all linked checklists and photos",
)
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)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@router.get(
"/visits/{visit_id}/checklists",
tags=["Checklists"],
summary="List checklists β€” get all checklist items for a visit",
)
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
@router.post(
"/visits/{visit_id}/checklists",
tags=["Checklists"],
status_code=201,
summary="Add checklist item β€” create a new item for a visit",
)
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
@router.patch(
"/checklists/{checklist_id}",
tags=["Checklists"],
summary="Update checklist item β€” toggle check, edit name or notes",
)
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
@router.delete(
"/checklists/{checklist_id}",
tags=["Checklists"],
summary="Delete checklist item β€” permanently remove an item",
)
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)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@router.get(
"/visits/{visit_id}/photos",
tags=["Photos"],
summary="List photos β€” get all photos for a visit",
)
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
@router.delete(
"/photos/{photo_id}",
tags=["Photos"],
summary="Delete photo β€” remove a photo by its ID",
)
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)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@router.get(
"/analytics/summary",
tags=["Analytics"],
summary="Analytics summary β€” overall visit statistics and completion rates",
)
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)
@router.get(
"/analytics/visits/{visit_id}",
tags=["Analytics"],
summary="Visit analytics β€” checklist completion and stats for one visit",
)
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
@router.get(
"/analytics/trends",
tags=["Analytics"],
summary="Visit trends β€” daily visit count over the last N days",
)
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)
@router.get(
"/reports/visits/{visit_id}/pdf",
tags=["Reports"],
summary="Generate visit PDF β€” download full visit report as PDF",
response_class=StreamingResponse,
)
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
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@router.get(
"/outlets",
tags=["Outlets"],
summary="List all outlets β€” coffee shop branches with average scores",
)
async def list_outlets(db: AsyncSession = Depends(get_db)):
"""Retrieve all outlets sorted by average assessment score."""
return await get_all_outlets(db)
@router.get(
"/outlets/{outlet_id}",
tags=["Outlets"],
summary="Outlet detail β€” visit history and category score breakdown",
)
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)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@router.get(
"/visits/{visit_id}/assessments",
tags=["Assessments"],
summary="List assessments β€” Ghost Shopper evaluation items for a visit",
)
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
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@router.get(
"/analytics/outlets",
tags=["Analytics"],
summary="Outlet comparison β€” compare all outlets by assessment scores",
)
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)
@router.get(
"/analytics/outlets/{outlet_id}/trends",
tags=["Analytics"],
summary="Outlet trends β€” weekly score progression for an outlet",
)
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
@router.get(
"/analytics/categories",
tags=["Analytics"],
summary="Category breakdown β€” global strengths and weaknesses",
)
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
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@router.post(
"/gs/visits",
tags=["Visits"],
summary="Create a new Ghost Shopper Visit with 21 default items"
)
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
@router.put(
"/assessments/{assessment_id}",
tags=["Visits"],
summary="Update an assessment item"
)
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
@router.post(
"/assessments/{assessment_id}/photos",
tags=["Photos"],
summary="Upload a photo for an assessment"
)
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
@router.get(
"/assessments/{assessment_id}/photos",
tags=["Photos"],
summary="Get photos for an assessment"
)
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)
@router.get(
"/photos/presigned-url",
tags=["Photos"],
summary="Request a presigned URL for direct photo upload to cloud storage",
)
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}",
},
)
@router.post(
"/photos/confirm",
tags=["Photos"],
summary="Confirm a completed photo upload β€” saves the storage URL to database",
)
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.
@router.post(
"/auth/register",
tags=["Auth"],
summary="Register a new user account",
)
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)})
@router.post(
"/auth/login",
tags=["Auth"],
summary="Authenticate and receive a JWT token",
)
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)})
@router.get(
"/auth/me",
tags=["Auth"],
summary="Get current authenticated user info",
)
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)})