ghost-shopper-api / python /controllers /visit_controller.py
azzaraqi
Deploy FastAPI backend with Supabase pooler support
b84ea83
Raw
History Blame Contribute Delete
10.9 kB
"""
Visit Controller β€” CRUD operations + validation logic
Handles all business logic for visits, checklists, and photos.
Controllers are thin: validate β†’ delegate to DB β†’ format response.
Endpoint naming convention:
- get_all_visits β†’ GET /visits
- get_visit_by_id β†’ GET /visits/{visit_id}
- create_new_visit β†’ POST /visits
- update_visit_by_id β†’ PUT /visits/{visit_id}
- delete_visit_by_id β†’ DELETE /visits/{visit_id}
"""
import uuid
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select, func, delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from models.visit_model import (
Visit,
VisitCreate,
VisitUpdate,
VisitResponse,
)
from models.checklist_model import (
Checklist,
ChecklistCreate,
ChecklistUpdate,
ChecklistResponse,
)
from models.photo_model import Photo, PhotoResponse
logger = logging.getLogger(__name__)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# VISIT CRUD
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def get_all_visits(
db: AsyncSession,
page: int = 1,
per_page: int = 20,
status: Optional[str] = None,
) -> dict:
"""
Retrieve all visits with pagination and optional status filter.
Returns:
dict with 'data' (list of VisitResponse) and 'meta' (pagination info)
"""
query = select(Visit)
# Optional filter
if status:
query = query.where(Visit.status == status)
# Count total
count_query = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_query)).scalar() or 0
# Paginate
offset = (page - 1) * per_page
query = query.order_by(Visit.created_at.desc()).offset(offset).limit(per_page)
result = await db.execute(query)
visits = result.scalars().all()
return {
"status": "success",
"data": [VisitResponse.model_validate(v) for v in visits],
"meta": {
"page": page,
"per_page": per_page,
"total": total,
"total_pages": (total + per_page - 1) // per_page if per_page > 0 else 0,
},
}
async def get_visit_by_id(db: AsyncSession, visit_id: uuid.UUID) -> dict | None:
"""
Retrieve a single visit by ID, including checklists and photos.
Returns:
dict with visit data, or None if not found
"""
query = (
select(Visit)
.options(selectinload(Visit.checklists), selectinload(Visit.photos))
.where(Visit.id == visit_id)
)
result = await db.execute(query)
visit = result.scalar_one_or_none()
if not visit:
return None
visit_data = VisitResponse.model_validate(visit).model_dump()
visit_data["checklists"] = [
ChecklistResponse.model_validate(c).model_dump()
for c in visit.checklists
]
visit_data["photos"] = [
PhotoResponse.model_validate(p).model_dump()
for p in visit.photos
]
return {"status": "success", "data": visit_data}
async def create_new_visit(db: AsyncSession, visit_data: VisitCreate) -> dict:
"""
Create a new visit record.
Returns:
dict with created visit data
"""
# Convert timezone-aware datetime to naive for PostgreSQL TIMESTAMP WITHOUT TIME ZONE
v_date = visit_data.visit_date
if v_date.tzinfo is not None:
v_date = v_date.replace(tzinfo=None)
new_visit = Visit(
title=visit_data.title,
description=visit_data.description,
location=visit_data.location,
visit_date=v_date,
status=visit_data.status or "pending",
outlet_id=visit_data.outlet_id,
ghost_shopper=visit_data.ghost_shopper,
visit_type=visit_data.visit_type,
comments=visit_data.comments,
suggestions=visit_data.suggestions,
)
db.add(new_visit)
await db.flush() # get the generated ID
await db.refresh(new_visit)
logger.info(f"Created visit: {new_visit.id} β€” '{new_visit.title}'")
return {
"status": "success",
"data": VisitResponse.model_validate(new_visit),
"message": "Visit created successfully",
}
async def update_visit_by_id(
db: AsyncSession, visit_id: uuid.UUID, update_data: VisitUpdate
) -> dict | None:
"""
Update an existing visit by ID (partial update).
Returns:
dict with updated visit data, or None if not found
"""
result = await db.execute(select(Visit).where(Visit.id == visit_id))
visit = result.scalar_one_or_none()
if not visit:
return None
# Apply only non-None fields
update_fields = update_data.model_dump(exclude_unset=True)
for field, value in update_fields.items():
setattr(visit, field, value)
visit.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(visit)
logger.info(f"Updated visit: {visit.id}")
return {
"status": "success",
"data": VisitResponse.model_validate(visit),
"message": "Visit updated successfully",
}
async def delete_visit_by_id(db: AsyncSession, visit_id: uuid.UUID) -> bool:
"""
Delete a visit by ID (cascades to checklists and photos).
Returns:
True if deleted, False if not found
"""
result = await db.execute(select(Visit).where(Visit.id == visit_id))
visit = result.scalar_one_or_none()
if not visit:
return False
await db.delete(visit)
await db.flush()
logger.info(f"Deleted visit: {visit_id}")
return True
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# CHECKLIST CRUD (nested under visit)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def get_checklists_by_visit(
db: AsyncSession, visit_id: uuid.UUID
) -> dict | None:
"""
Retrieve all checklist items for a specific visit.
Returns None if visit does not exist.
"""
# Verify visit exists
visit = (await db.execute(select(Visit).where(Visit.id == visit_id))).scalar_one_or_none()
if not visit:
return None
result = await db.execute(
select(Checklist)
.where(Checklist.visit_id == visit_id)
.order_by(Checklist.created_at.asc())
)
items = result.scalars().all()
return {
"status": "success",
"data": [ChecklistResponse.model_validate(c) for c in items],
}
async def create_checklist_item(
db: AsyncSession, visit_id: uuid.UUID, item_data: ChecklistCreate
) -> dict | None:
"""
Add a new checklist item to a visit.
Returns None if visit does not exist.
"""
visit = (await db.execute(select(Visit).where(Visit.id == visit_id))).scalar_one_or_none()
if not visit:
return None
new_item = Checklist(
visit_id=visit_id,
item_name=item_data.item_name,
is_checked=item_data.is_checked,
notes=item_data.notes,
checked_at=datetime.utcnow() if item_data.is_checked else None,
)
db.add(new_item)
await db.flush()
await db.refresh(new_item)
logger.info(f"Created checklist item: {new_item.id} for visit {visit_id}")
return {
"status": "success",
"data": ChecklistResponse.model_validate(new_item),
"message": "Checklist item added successfully",
}
async def update_checklist_item(
db: AsyncSession, checklist_id: uuid.UUID, update_data: ChecklistUpdate
) -> dict | None:
"""
Update a checklist item by its ID.
Returns None if checklist item does not exist.
"""
result = await db.execute(
select(Checklist).where(Checklist.id == checklist_id)
)
item = result.scalar_one_or_none()
if not item:
return None
update_fields = update_data.model_dump(exclude_unset=True)
for field, value in update_fields.items():
setattr(item, field, value)
# Auto-set checked_at timestamp
if "is_checked" in update_fields:
item.checked_at = datetime.utcnow() if update_fields["is_checked"] else None
item.updated_at = datetime.utcnow()
await db.flush()
await db.refresh(item)
logger.info(f"Updated checklist item: {checklist_id}")
return {
"status": "success",
"data": ChecklistResponse.model_validate(item),
"message": "Checklist item updated successfully",
}
async def delete_checklist_item(
db: AsyncSession, checklist_id: uuid.UUID
) -> bool:
"""Delete a checklist item by its ID."""
result = await db.execute(
select(Checklist).where(Checklist.id == checklist_id)
)
item = result.scalar_one_or_none()
if not item:
return False
await db.delete(item)
await db.flush()
logger.info(f"Deleted checklist item: {checklist_id}")
return True
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# PHOTO CRUD (nested under visit)
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
async def get_photos_by_visit(
db: AsyncSession, visit_id: uuid.UUID
) -> dict | None:
"""
Retrieve all photos for a specific visit.
Returns None if visit does not exist.
"""
visit = (await db.execute(select(Visit).where(Visit.id == visit_id))).scalar_one_or_none()
if not visit:
return None
result = await db.execute(
select(Photo)
.where(Photo.visit_id == visit_id)
.order_by(Photo.uploaded_at.desc())
)
photos = result.scalars().all()
return {
"status": "success",
"data": [PhotoResponse.model_validate(p) for p in photos],
}
async def delete_photo_by_id(
db: AsyncSession, photo_id: uuid.UUID
) -> bool:
"""Delete a photo record by its ID."""
result = await db.execute(select(Photo).where(Photo.id == photo_id))
photo = result.scalar_one_or_none()
if not photo:
return False
await db.delete(photo)
await db.flush()
logger.info(f"Deleted photo: {photo_id}")
return True