# app/crud/item.py from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from sqlalchemy.orm import selectinload # Ensure selectinload is imported from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError from typing import Optional, List as PyList from datetime import datetime, timezone from app.models import Item as ItemModel, User as UserModel # Import UserModel for type hints if needed for selectinload from app.schemas.item import ItemCreate, ItemUpdate from app.core.exceptions import ( ItemNotFoundError, DatabaseConnectionError, DatabaseIntegrityError, DatabaseQueryError, DatabaseTransactionError, ConflictError, ItemOperationError # Add if specific item operation errors are needed ) async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_id: int) -> ItemModel: """Creates a new item record for a specific list.""" try: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: db_item = ItemModel( name=item_in.name, quantity=item_in.quantity, list_id=list_id, added_by_id=user_id, is_complete=False ) db.add(db_item) await db.flush() # Assigns ID # Re-fetch with relationships stmt = ( select(ItemModel) .where(ItemModel.id == db_item.id) .options( selectinload(ItemModel.added_by_user), selectinload(ItemModel.completed_by_user) # Will be None but loads relationship ) ) result = await db.execute(stmt) loaded_item = result.scalar_one_or_none() if loaded_item is None: await transaction.rollback() # Should be handled by context manager on raise, but explicit for clarity raise ItemOperationError("Failed to load item after creation.") # Define ItemOperationError await transaction.commit() return loaded_item except IntegrityError as e: # Context manager handles rollback on error raise DatabaseIntegrityError(f"Failed to create item: {str(e)}") except OperationalError as e: raise DatabaseConnectionError(f"Database connection error: {str(e)}") except SQLAlchemyError as e: raise DatabaseTransactionError(f"Failed to create item: {str(e)}") # Removed generic Exception block as SQLAlchemyError should cover DB issues, # and context manager handles rollback. async def get_items_by_list_id(db: AsyncSession, list_id: int) -> PyList[ItemModel]: """Gets all items belonging to a specific list, ordered by creation time.""" try: stmt = ( select(ItemModel) .where(ItemModel.list_id == list_id) .options( selectinload(ItemModel.added_by_user), selectinload(ItemModel.completed_by_user) ) .order_by(ItemModel.created_at.asc()) ) result = await db.execute(stmt) return result.scalars().all() except OperationalError as e: raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") except SQLAlchemyError as e: raise DatabaseQueryError(f"Failed to query items: {str(e)}") async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]: """Gets a single item by its ID.""" try: stmt = ( select(ItemModel) .where(ItemModel.id == item_id) .options( selectinload(ItemModel.added_by_user), selectinload(ItemModel.completed_by_user), selectinload(ItemModel.list) # Often useful to get the parent list ) ) result = await db.execute(stmt) return result.scalars().first() except OperationalError as e: raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}") except SQLAlchemyError as e: raise DatabaseQueryError(f"Failed to query item: {str(e)}") async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel: """Updates an existing item record, checking for version conflicts.""" try: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: if item_db.version != item_in.version: # No need to rollback here, as the transaction hasn't committed. # The context manager will handle rollback if an exception is raised. raise ConflictError( f"Item '{item_db.name}' (ID: {item_db.id}) has been modified. " f"Your version is {item_in.version}, current version is {item_db.version}. Please refresh." ) update_data = item_in.model_dump(exclude_unset=True, exclude={'version'}) if 'is_complete' in update_data: if update_data['is_complete'] is True: if item_db.completed_by_id is None: update_data['completed_by_id'] = user_id else: update_data['completed_by_id'] = None for key, value in update_data.items(): setattr(item_db, key, value) item_db.version += 1 db.add(item_db) # Mark as dirty await db.flush() # Re-fetch with relationships stmt = ( select(ItemModel) .where(ItemModel.id == item_db.id) .options( selectinload(ItemModel.added_by_user), selectinload(ItemModel.completed_by_user), selectinload(ItemModel.list) ) ) result = await db.execute(stmt) updated_item = result.scalar_one_or_none() if updated_item is None: # Should not happen # Rollback will be handled by context manager on raise raise ItemOperationError("Failed to load item after update.") await transaction.commit() return updated_item except IntegrityError as e: raise DatabaseIntegrityError(f"Failed to update item due to integrity constraint: {str(e)}") except OperationalError as e: raise DatabaseConnectionError(f"Database connection error while updating item: {str(e)}") except ConflictError: # Re-raise ConflictError, rollback handled by context manager raise except SQLAlchemyError as e: raise DatabaseTransactionError(f"Failed to update item: {str(e)}") async def delete_item(db: AsyncSession, item_db: ItemModel) -> None: """Deletes an item record. Version check should be done by the caller (API endpoint).""" try: async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: await db.delete(item_db) await transaction.commit() # No return needed for None except OperationalError as e: # Rollback handled by context manager raise DatabaseConnectionError(f"Database connection error while deleting item: {str(e)}") except SQLAlchemyError as e: # Rollback handled by context manager raise DatabaseTransactionError(f"Failed to delete item: {str(e)}") # Ensure ItemOperationError is defined in app.core.exceptions if used # Example: class ItemOperationError(AppException): pass