# app/api/v1/endpoints/lists.py import logging from typing import List as PyList, Optional # Alias for Python List type hint from fastapi import APIRouter, Depends, HTTPException, status, Response, Query # Added Query from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_transactional_session from app.auth import current_active_user from app.models import User as UserModel from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail from app.schemas.message import Message # For simple responses from app.crud import list as crud_list from app.crud import group as crud_group # Need for group membership check from app.schemas.list import ListStatus, ListStatusWithId from app.schemas.expense import ExpensePublic # Import ExpensePublic from app.core.exceptions import ( GroupMembershipError, ListNotFoundError, ListPermissionError, ListStatusNotFoundError, ConflictError, # Added ConflictError DatabaseIntegrityError # Added DatabaseIntegrityError ) logger = logging.getLogger(__name__) router = APIRouter() @router.post( "", # Route relative to prefix "/lists" response_model=ListPublic, # Return basic list info on creation status_code=status.HTTP_201_CREATED, summary="Create New List", tags=["Lists"], responses={ status.HTTP_409_CONFLICT: { "description": "Conflict: A list with this name already exists in the specified group", "model": ListPublic } } ) async def create_list( list_in: ListCreate, db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), ): """ Creates a new shopping list. - If `group_id` is provided, the user must be a member of that group. - If `group_id` is null, it's a personal list. - If a list with the same name already exists in the group, returns 409 with the existing list. """ logger.info(f"User {current_user.email} creating list: {list_in.name}") group_id = list_in.group_id # Permission Check: If sharing with a group, verify membership if group_id: is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id) if not is_member: logger.warning(f"User {current_user.email} attempted to create list in group {group_id} but is not a member.") raise GroupMembershipError(group_id, "create lists") try: created_list = await crud_list.create_list(db=db, list_in=list_in, creator_id=current_user.id) logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.") return created_list except DatabaseIntegrityError as e: # Check if this is a unique constraint violation if "unique constraint" in str(e).lower(): # Find the existing list with the same name in the group existing_list = await crud_list.get_list_by_name_and_group( db=db, name=list_in.name, group_id=group_id, user_id=current_user.id ) if existing_list: logger.info(f"List '{list_in.name}' already exists in group {group_id}. Returning existing list.") raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"A list named '{list_in.name}' already exists in this group.", headers={"X-Existing-List": str(existing_list.id)} ) # If it's not a unique constraint or we couldn't find the existing list, re-raise raise @router.get( "", # Route relative to prefix "/lists" response_model=PyList[ListDetail], # Return a list of detailed list info including items summary="List Accessible Lists", tags=["Lists"] ) async def read_lists( db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), # Add pagination parameters later if needed: skip: int = 0, limit: int = 100 ): """ Retrieves lists accessible to the current user: - Personal lists created by the user. - Lists belonging to groups the user is a member of. """ logger.info(f"Fetching lists accessible to user: {current_user.email}") lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id) return lists @router.get( "/statuses", response_model=PyList[ListStatusWithId], summary="Get Status for Multiple Lists", tags=["Lists"] ) async def read_lists_statuses( ids: PyList[int] = Query(...), db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), ): """ Retrieves the status for a list of lists. - `updated_at`: The timestamp of the last update to the list itself. - `item_count`: The total number of items in the list. The user must have permission to view each list requested. Lists that the user does not have permission for will be omitted from the response. """ logger.info(f"User {current_user.email} requesting statuses for list IDs: {ids}") statuses = await crud_list.get_lists_statuses_by_ids(db=db, list_ids=ids, user_id=current_user.id) # The CRUD function returns a list of Row objects, so we map them to the Pydantic model return [ ListStatusWithId( id=s.id, updated_at=s.updated_at, item_count=s.item_count, latest_item_updated_at=s.latest_item_updated_at ) for s in statuses ] @router.get( "/{list_id}", response_model=ListDetail, # Return detailed list info including items summary="Get List Details", tags=["Lists"] ) async def read_list( list_id: int, db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), ): """ Retrieves details for a specific list, including its items, if the user has permission (creator or group member). """ logger.info(f"User {current_user.email} requesting details for list ID: {list_id}") # The check_list_permission function will raise appropriate exceptions list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) return list_db @router.put( "/{list_id}", response_model=ListPublic, # Return updated basic info summary="Update List", tags=["Lists"], responses={ # Add 409 to responses status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified by someone else"} } ) async def update_list( list_id: int, list_in: ListUpdate, db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), ): """ Updates a list's details (name, description, is_complete). Requires user to be the creator or a member of the list's group. The client MUST provide the current `version` of the list in the `list_in` payload. If the version does not match, a 409 Conflict is returned. """ logger.info(f"User {current_user.email} attempting to update list ID: {list_id} with version {list_in.version}") list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) try: updated_list = await crud_list.update_list(db=db, list_db=list_db, list_in=list_in) logger.info(f"List {list_id} updated successfully by user {current_user.email} to version {updated_list.version}.") return updated_list except ConflictError as e: # Catch and re-raise as HTTPException for proper FastAPI response logger.warning(f"Conflict updating list {list_id} for user {current_user.email}: {str(e)}") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) except Exception as e: # Catch other potential errors from crud operation logger.error(f"Error updating list {list_id} for user {current_user.email}: {str(e)}") # Consider a more generic error, but for now, let's keep it specific if possible # Re-raising might be better if crud layer already raises appropriate HTTPExceptions raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating the list.") @router.delete( "/{list_id}", status_code=status.HTTP_204_NO_CONTENT, # Standard for successful DELETE with no body summary="Delete List", tags=["Lists"], responses={ # Add 409 to responses status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot delete specified version"} } ) async def delete_list( list_id: int, expected_version: Optional[int] = Query(None, description="The expected version of the list to delete for optimistic locking."), db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), ): """ Deletes a list. Requires user to be the creator of the list. If `expected_version` is provided and does not match the list's current version, a 409 Conflict is returned. """ logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}, expected version: {expected_version}") # Use the helper, requiring creator permission list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True) if expected_version is not None and list_db.version != expected_version: logger.warning( f"Conflict deleting list {list_id} for user {current_user.email}. " f"Expected version {expected_version}, actual version {list_db.version}." ) raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=f"List has been modified. Expected version {expected_version}, but current version is {list_db.version}. Please refresh." ) await crud_list.delete_list(db=db, list_db=list_db) logger.info(f"List {list_id} (version: {list_db.version}) deleted successfully by user {current_user.email}.") return Response(status_code=status.HTTP_204_NO_CONTENT) @router.get( "/{list_id}/status", response_model=ListStatus, summary="Get List Status", tags=["Lists"] ) async def read_list_status( list_id: int, db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), ): """ Retrieves the update timestamp and item count for a specific list if the user has permission (creator or group member). """ logger.info(f"User {current_user.email} requesting status for list ID: {list_id}") # The check_list_permission is not needed here as get_list_status handles not found await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) return await crud_list.get_list_status(db=db, list_id=list_id) @router.get( "/{list_id}/expenses", response_model=PyList[ExpensePublic], summary="Get Expenses for List", tags=["Lists", "Expenses"] ) async def read_list_expenses( list_id: int, skip: int = Query(0, ge=0), limit: int = Query(100, ge=1, le=200), db: AsyncSession = Depends(get_transactional_session), current_user: UserModel = Depends(current_active_user), ): """ Retrieves expenses associated with a specific list if the user has permission (creator or group member). """ from app.crud import expense as crud_expense logger.info(f"User {current_user.email} requesting expenses for list ID: {list_id}") # Check if user has permission to access this list await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) # Get expenses for this list expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit) return expenses