
Some checks failed
Deploy to Production, build images and push to Gitea Registry / build_and_push (pull_request) Failing after 1m24s
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.
353 lines
14 KiB
Python
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)}") |