
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.
310 lines
12 KiB
Python
310 lines
12 KiB
Python
import logging
|
|
from typing import List as PyList, Optional
|
|
from fastapi import APIRouter, Depends, HTTPException, status, Response, 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.crud import list as crud_list
|
|
from app.crud import group as crud_group
|
|
from app.schemas.list import ListStatus, ListStatusWithId
|
|
from app.schemas.expense import ExpensePublic
|
|
from app.core.exceptions import (
|
|
GroupMembershipError,
|
|
ConflictError,
|
|
DatabaseIntegrityError
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
@router.post(
|
|
"",
|
|
response_model=ListPublic,
|
|
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
|
|
|
|
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:
|
|
if "unique constraint" in str(e).lower():
|
|
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)}
|
|
)
|
|
raise
|
|
|
|
|
|
@router.get(
|
|
"",
|
|
response_model=PyList[ListDetail],
|
|
summary="List Accessible Lists",
|
|
tags=["Lists"]
|
|
)
|
|
async def read_lists(
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""
|
|
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(
|
|
"/archived",
|
|
response_model=PyList[ListDetail],
|
|
summary="List Archived Lists",
|
|
tags=["Lists"]
|
|
)
|
|
async def read_archived_lists(
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""
|
|
Retrieves archived lists for the current user.
|
|
"""
|
|
logger.info(f"Fetching archived lists for user: {current_user.email}")
|
|
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id, include_archived=True)
|
|
return [l for l in lists if l.archived_at]
|
|
|
|
|
|
@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)
|
|
|
|
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,
|
|
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}")
|
|
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,
|
|
summary="Update List",
|
|
tags=["Lists"],
|
|
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:
|
|
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:
|
|
logger.error(f"Error updating list {list_id} for user {current_user.email}: {str(e)}")
|
|
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,
|
|
summary="Archive List",
|
|
tags=["Lists"],
|
|
responses={
|
|
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot archive specified version"}
|
|
}
|
|
)
|
|
async def archive_list_endpoint(
|
|
list_id: int,
|
|
expected_version: Optional[int] = Query(None, description="The expected version of the list to archive for optimistic locking."),
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""
|
|
Archives 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 archive list ID: {list_id}, expected version: {expected_version}")
|
|
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 archiving 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.archive_list(db=db, list_db=list_db)
|
|
logger.info(f"List {list_id} (version: {list_db.version}) archived successfully by user {current_user.email}.")
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.post(
|
|
"/{list_id}/unarchive",
|
|
response_model=ListPublic,
|
|
summary="Unarchive List",
|
|
tags=["Lists"]
|
|
)
|
|
async def unarchive_list_endpoint(
|
|
list_id: int,
|
|
db: AsyncSession = Depends(get_transactional_session),
|
|
current_user: UserModel = Depends(current_active_user),
|
|
):
|
|
"""
|
|
Restores an archived list.
|
|
"""
|
|
logger.info(f"User {current_user.email} attempting to unarchive list ID: {list_id}")
|
|
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id, require_creator=True)
|
|
|
|
if not list_db.archived_at:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="List is not archived.")
|
|
|
|
updated_list = await crud_list.unarchive_list(db=db, list_db=list_db)
|
|
|
|
logger.info(f"List {list_id} unarchived successfully by user {current_user.email}.")
|
|
return updated_list
|
|
|
|
|
|
@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}")
|
|
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}")
|
|
|
|
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
|
|
|
|
expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit)
|
|
return expenses |