mitlist/be/app/crud/item.py

173 lines
7.5 KiB
Python

# 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