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