mitlist/be/app/crud/list.py
mohamad f49e15c05c
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m24s
feat: Introduce FastAPI and Vue.js guidelines, enhance API structure, and add caching support
This commit adds new guidelines for FastAPI and Vue.js development, emphasizing best practices for component structure, API performance, and data handling. It also introduces caching mechanisms using Redis for improved performance and updates the API structure to streamline authentication and user management. Additionally, new endpoints for categories and time entries are implemented, enhancing the overall functionality of the application.
2025-06-09 21:02:51 +02:00

353 lines
14 KiB
Python

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy import or_, and_, delete as sql_delete, func as sql_func, desc
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List as PyList
import logging
from datetime import datetime, timezone
from app.schemas.list import ListStatus
from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel
from app.schemas.list import ListCreate, ListUpdate
from app.core.exceptions import (
ListNotFoundError,
ListPermissionError,
ListCreatorRequiredError,
DatabaseConnectionError,
DatabaseIntegrityError,
DatabaseQueryError,
DatabaseTransactionError,
ConflictError,
ListOperationError
)
logger = logging.getLogger(__name__)
async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel:
"""Creates a new list record."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Start transaction
db_list = ListModel(
name=list_in.name,
description=list_in.description,
group_id=list_in.group_id,
created_by_id=creator_id,
is_complete=False
)
db.add(db_list)
await db.flush()
stmt = (
select(ListModel)
.where(ListModel.id == db_list.id)
.options(
selectinload(ListModel.creator),
selectinload(ListModel.group)
)
)
result = await db.execute(stmt)
loaded_list = result.scalar_one_or_none()
if loaded_list is None:
raise ListOperationError("Failed to load list after creation.")
return loaded_list
except IntegrityError as e:
logger.error(f"Database integrity error during list creation: {str(e)}", exc_info=True)
raise DatabaseIntegrityError(f"Failed to create list: {str(e)}")
except OperationalError as e:
logger.error(f"Database connection error during list creation: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during list creation: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to create list: {str(e)}")
async def get_lists_for_user(db: AsyncSession, user_id: int, include_archived: bool = False) -> PyList[ListModel]:
"""Gets all lists accessible by a user."""
try:
group_ids_result = await db.execute(
select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id)
)
user_group_ids = group_ids_result.scalars().all()
conditions = [
and_(ListModel.created_by_id == user_id, ListModel.group_id.is_(None))
]
if user_group_ids:
conditions.append(ListModel.group_id.in_(user_group_ids))
query = select(ListModel).where(or_(*conditions))
if not include_archived:
query = query.where(ListModel.archived_at.is_(None))
query = query.options(
selectinload(ListModel.creator),
selectinload(ListModel.group),
selectinload(ListModel.items).options(
joinedload(ItemModel.added_by_user),
joinedload(ItemModel.completed_by_user)
)
).order_by(ListModel.updated_at.desc())
result = await db.execute(query)
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 user lists: {str(e)}")
async def get_list_by_id(db: AsyncSession, list_id: int, load_items: bool = False) -> Optional[ListModel]:
"""Gets a single list by ID, optionally loading its items."""
try:
query = (
select(ListModel)
.where(ListModel.id == list_id)
.options(
selectinload(ListModel.creator),
selectinload(ListModel.group)
)
)
if load_items:
query = query.options(
selectinload(ListModel.items).options(
joinedload(ItemModel.added_by_user),
joinedload(ItemModel.completed_by_user)
)
)
result = await db.execute(query)
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 list: {str(e)}")
async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate) -> ListModel:
"""Updates an existing list record, checking for version conflicts."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
if list_db.version != list_in.version:
raise ConflictError(
f"List '{list_db.name}' (ID: {list_db.id}) has been modified. "
f"Your version is {list_in.version}, current version is {list_db.version}. Please refresh."
)
update_data = list_in.model_dump(exclude_unset=True, exclude={'version'})
for key, value in update_data.items():
setattr(list_db, key, value)
list_db.version += 1
db.add(list_db) # Add the already attached list_db to mark it dirty for the session
await db.flush()
stmt = (
select(ListModel)
.where(ListModel.id == list_db.id)
.options(
selectinload(ListModel.creator),
selectinload(ListModel.group)
)
)
result = await db.execute(stmt)
updated_list = result.scalar_one_or_none()
if updated_list is None:
raise ListOperationError("Failed to load list after update.")
return updated_list
except IntegrityError as e:
logger.error(f"Database integrity error during list update: {str(e)}", exc_info=True)
raise DatabaseIntegrityError(f"Failed to update list due to integrity constraint: {str(e)}")
except OperationalError as e:
logger.error(f"Database connection error while updating list: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while updating list: {str(e)}")
except ConflictError:
raise
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during list update: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to update list: {str(e)}")
async def archive_list(db: AsyncSession, list_db: ListModel) -> ListModel:
"""Archives a list record by setting the archived_at timestamp."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
list_db.archived_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(list_db)
return list_db
except OperationalError as e:
logger.error(f"Database connection error while archiving list: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while archiving list: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while archiving list: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to archive list: {str(e)}")
async def unarchive_list(db: AsyncSession, list_db: ListModel) -> ListModel:
"""Unarchives a list record by setting the archived_at timestamp to None."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
list_db.archived_at = None
await db.flush()
await db.refresh(list_db)
return list_db
except OperationalError as e:
logger.error(f"Database connection error while unarchiving list: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while unarchiving list: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while unarchiving list: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to unarchive list: {str(e)}")
async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> ListModel:
"""Fetches a list and verifies user permission."""
try:
list_db = await get_list_by_id(db, list_id=list_id, load_items=True)
if not list_db:
raise ListNotFoundError(list_id)
is_creator = list_db.created_by_id == user_id
if require_creator:
if not is_creator:
raise ListCreatorRequiredError(list_id, "access")
return list_db
if is_creator:
return list_db
if list_db.group_id:
from app.crud.group import is_user_member
is_member = await is_user_member(db, group_id=list_db.group_id, user_id=user_id)
if not is_member:
raise ListPermissionError(list_id)
return list_db
else:
raise ListPermissionError(list_id)
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to check list permissions: {str(e)}")
async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
"""Gets the update timestamps and item count for a list."""
try:
query = (
select(
ListModel.updated_at,
sql_func.count(ItemModel.id).label("item_count"),
sql_func.max(ItemModel.updated_at).label("latest_item_updated_at")
)
.select_from(ListModel)
.outerjoin(ItemModel, ItemModel.list_id == ListModel.id)
.where(ListModel.id == list_id)
.group_by(ListModel.id)
)
result = await db.execute(query)
status = result.first()
if status is None:
raise ListNotFoundError(list_id)
return ListStatus(
updated_at=status.updated_at,
item_count=status.item_count,
latest_item_updated_at=status.latest_item_updated_at
)
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to get list status: {str(e)}")
async def get_list_by_name_and_group(
db: AsyncSession,
name: str,
group_id: Optional[int],
user_id: int # user_id is for permission check, not direct list attribute
) -> Optional[ListModel]:
"""
Gets a list by name and group, ensuring the user has permission to access it.
Used for conflict resolution when creating lists.
"""
try:
base_query = select(ListModel).where(ListModel.name == name)
if group_id is not None:
base_query = base_query.where(ListModel.group_id == group_id)
else:
base_query = base_query.where(ListModel.group_id.is_(None))
base_query = base_query.options(
selectinload(ListModel.creator),
selectinload(ListModel.group)
)
list_result = await db.execute(base_query)
target_list = list_result.scalar_one_or_none()
if not target_list:
return None
is_creator = target_list.created_by_id == user_id
if is_creator:
return target_list
if target_list.group_id:
from app.crud.group import is_user_member
is_member_of_group = await is_user_member(db, group_id=target_list.group_id, user_id=user_id)
if is_member_of_group:
return target_list
return None
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query list by name and group: {str(e)}")
async def get_lists_statuses_by_ids(db: AsyncSession, list_ids: PyList[int], user_id: int) -> PyList[ListModel]:
"""
Gets status for a list of lists if the user has permission.
Status includes list updated_at and a count of its items.
"""
if not list_ids:
return []
try:
group_ids_result = await db.execute(
select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id)
)
user_group_ids = group_ids_result.scalars().all()
permission_filter = or_(
and_(ListModel.created_by_id == user_id, ListModel.group_id.is_(None)),
ListModel.group_id.in_(user_group_ids)
)
query = (
select(
ListModel.id,
ListModel.updated_at,
sql_func.count(ItemModel.id).label("item_count"),
sql_func.max(ItemModel.updated_at).label("latest_item_updated_at")
)
.outerjoin(ItemModel, ListModel.id == ItemModel.list_id)
.where(
and_(
ListModel.id.in_(list_ids),
permission_filter
)
)
.group_by(ListModel.id)
)
result = await db.execute(query)
return result.all()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to get lists statuses: {str(e)}")