Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |