diff --git a/be/alembic/versions/d25788f63e2c_add_list_and_item_tables.py b/be/alembic/versions/d25788f63e2c_add_list_and_item_tables.py new file mode 100644 index 0000000..3a36da9 --- /dev/null +++ b/be/alembic/versions/d25788f63e2c_add_list_and_item_tables.py @@ -0,0 +1,73 @@ +"""Add list and item tables + +Revision ID: d25788f63e2c +Revises: d90ab7116920 +Create Date: 2025-03-30 19:43:49.925240 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd25788f63e2c' +down_revision: Union[str, None] = 'd90ab7116920' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('lists', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('created_by_id', sa.Integer(), nullable=False), + sa.Column('group_id', sa.Integer(), nullable=True), + sa.Column('is_complete', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['created_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['group_id'], ['groups.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_lists_id'), 'lists', ['id'], unique=False) + op.create_index(op.f('ix_lists_name'), 'lists', ['name'], unique=False) + op.create_table('items', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('list_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('quantity', sa.String(), nullable=True), + sa.Column('is_complete', sa.Boolean(), nullable=False), + sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=True), + sa.Column('added_by_id', sa.Integer(), nullable=False), + sa.Column('completed_by_id', sa.Integer(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.ForeignKeyConstraint(['added_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['completed_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['list_id'], ['lists.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_items_id'), 'items', ['id'], unique=False) + op.create_index(op.f('ix_items_name'), 'items', ['name'], unique=False) + op.drop_index('ix_invites_code', table_name='invites') + op.create_index(op.f('ix_invites_code'), 'invites', ['code'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_invites_code'), table_name='invites') + op.create_index('ix_invites_code', 'invites', ['code'], unique=True) + op.drop_index(op.f('ix_items_name'), table_name='items') + op.drop_index(op.f('ix_items_id'), table_name='items') + op.drop_table('items') + op.drop_index(op.f('ix_lists_name'), table_name='lists') + op.drop_index(op.f('ix_lists_id'), table_name='lists') + op.drop_table('lists') + # ### end Alembic commands ### diff --git a/be/app/api/v1/api.py b/be/app/api/v1/api.py index 5616c14..84afb76 100644 --- a/be/app/api/v1/api.py +++ b/be/app/api/v1/api.py @@ -6,6 +6,8 @@ from app.api.v1.endpoints import auth from app.api.v1.endpoints import users from app.api.v1.endpoints import groups from app.api.v1.endpoints import invites +from app.api.v1.endpoints import lists +from app.api.v1.endpoints import items api_router_v1 = APIRouter() @@ -14,6 +16,7 @@ api_router_v1.include_router(auth.router, prefix="/auth", tags=["Authentication" api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) api_router_v1.include_router(groups.router, prefix="/groups", tags=["Groups"]) api_router_v1.include_router(invites.router, prefix="/invites", tags=["Invites"]) - +api_router_v1.include_router(lists.router, prefix="/lists", tags=["Lists"]) +api_router_v1.include_router(items.router, tags=["Items"]) # Add other v1 endpoint routers here later # e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"]) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/items.py b/be/app/api/v1/endpoints/items.py new file mode 100644 index 0000000..7cff34b --- /dev/null +++ b/be/app/api/v1/endpoints/items.py @@ -0,0 +1,150 @@ +# app/api/v1/endpoints/items.py +import logging +from typing import List as PyList + +from fastapi import APIRouter, Depends, HTTPException, status, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.api.dependencies import get_current_user +# --- Import Models Correctly --- +from app.models import User as UserModel +from app.models import Item as ItemModel # <-- IMPORT Item and alias it +# --- End Import Models --- +from app.schemas.item import ItemCreate, ItemUpdate, ItemPublic +from app.crud import item as crud_item +from app.crud import list as crud_list + +logger = logging.getLogger(__name__) +router = APIRouter() + +# --- Helper Dependency for Item Permissions --- +# Now ItemModel is defined before being used as a type hint +async def get_item_and_verify_access( + item_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user) +) -> ItemModel: # Now this type hint is valid + item_db = await crud_item.get_item_by_id(db, item_id=item_id) + if not item_db: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + + # Check permission on the parent list + list_db = await crud_list.check_list_permission(db=db, list_id=item_db.list_id, user_id=current_user.id) + if not list_db: + # User doesn't have access to the list this item belongs to + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to access this item's list") + return item_db # Return the fetched item if authorized + + +# --- Endpoints --- + +@router.post( + "/lists/{list_id}/items", # Nested under lists + response_model=ItemPublic, + status_code=status.HTTP_201_CREATED, + summary="Add Item to List", + tags=["Items"] +) +async def create_list_item( + list_id: int, + item_in: ItemCreate, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """Adds a new item to a specific list. User must have access to the list.""" + logger.info(f"User {current_user.email} adding item to list {list_id}: {item_in.name}") + # Verify user has access to the target list + list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + if not list_db: + # Check if list exists at all for correct error code + exists = await crud_list.get_list_by_id(db, list_id) + status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN + detail = "List not found" if not exists else "You do not have permission to add items to this list" + logger.warning(f"Add item failed for list {list_id} by user {current_user.email}: {detail}") + raise HTTPException(status_code=status_code, detail=detail) + + created_item = await crud_item.create_item( + db=db, item_in=item_in, list_id=list_id, user_id=current_user.id + ) + logger.info(f"Item '{created_item.name}' (ID: {created_item.id}) added to list {list_id} by user {current_user.email}.") + return created_item + + +@router.get( + "/lists/{list_id}/items", # Nested under lists + response_model=PyList[ItemPublic], + summary="List Items in List", + tags=["Items"] +) +async def read_list_items( + list_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), + # Add sorting/filtering params later if needed: sort_by: str = 'created_at', order: str = 'asc' +): + """Retrieves all items for a specific list if the user has access.""" + logger.info(f"User {current_user.email} listing items for list {list_id}") + # Verify user has access to the list + list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + if not list_db: + exists = await crud_list.get_list_by_id(db, list_id) + status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN + detail = "List not found" if not exists else "You do not have permission to view items in this list" + logger.warning(f"List items failed for list {list_id} by user {current_user.email}: {detail}") + raise HTTPException(status_code=status_code, detail=detail) + + items = await crud_item.get_items_by_list_id(db=db, list_id=list_id) + return items + + +@router.put( + "/items/{item_id}", # Operate directly on item ID + response_model=ItemPublic, + summary="Update Item", + tags=["Items"] +) +async def update_item( + item_id: int, # Item ID from path + item_in: ItemUpdate, + item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), # Need user ID for completed_by +): + """ + Updates an item's details (name, quantity, is_complete, price). + User must have access to the list the item belongs to. + Sets/unsets `completed_by_id` based on `is_complete` flag. + """ + logger.info(f"User {current_user.email} attempting to update item ID: {item_id}") + # Permission check is handled by get_item_and_verify_access dependency + + updated_item = await crud_item.update_item( + db=db, item_db=item_db, item_in=item_in, user_id=current_user.id + ) + logger.info(f"Item {item_id} updated successfully by user {current_user.email}.") + return updated_item + + +@router.delete( + "/items/{item_id}", # Operate directly on item ID + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete Item", + tags=["Items"] +) +async def delete_item( + item_id: int, # Item ID from path + item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), # Log who deleted it +): + """ + Deletes an item. User must have access to the list the item belongs to. + (MVP: Any member with list access can delete items). + """ + logger.info(f"User {current_user.email} attempting to delete item ID: {item_id}") + # Permission check is handled by get_item_and_verify_access dependency + + await crud_item.delete_item(db=db, item_db=item_db) + logger.info(f"Item {item_id} deleted successfully by user {current_user.email}.") + return Response(status_code=status.HTTP_204_NO_CONTENT) \ No newline at end of file diff --git a/be/app/api/v1/endpoints/lists.py b/be/app/api/v1/endpoints/lists.py new file mode 100644 index 0000000..25d833b --- /dev/null +++ b/be/app/api/v1/endpoints/lists.py @@ -0,0 +1,211 @@ +# app/api/v1/endpoints/lists.py +import logging +from typing import List as PyList # Alias for Python List type hint + +from fastapi import APIRouter, Depends, HTTPException, status, Response +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.api.dependencies import get_current_user +from app.models import User as UserModel +from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail +from app.schemas.message import Message # For simple responses +from app.crud import list as crud_list +from app.crud import group as crud_group # Need for group membership check +from app.schemas.list import ListStatus + +logger = logging.getLogger(__name__) +router = APIRouter() + +@router.post( + "", # Route relative to prefix "/lists" + response_model=ListPublic, # Return basic list info on creation + status_code=status.HTTP_201_CREATED, + summary="Create New List", + tags=["Lists"] +) +async def create_list( + list_in: ListCreate, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_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. + """ + logger.info(f"User {current_user.email} creating list: {list_in.name}") + group_id = list_in.group_id + + # Permission Check: If sharing with a group, verify membership + 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 HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You are not a member of the specified group", + ) + + 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 + + +@router.get( + "", # Route relative to prefix "/lists" + response_model=PyList[ListPublic], # Return a list of basic list info + summary="List Accessible Lists", + tags=["Lists"] +) +async def read_lists( + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), + # Add pagination parameters later if needed: skip: int = 0, limit: int = 100 +): + """ + 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( + "/{list_id}", + response_model=ListDetail, # Return detailed list info including items + summary="Get List Details", + tags=["Lists"] +) +async def read_list( + list_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_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}") + # Use the helper to fetch and check permission simultaneously + list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + + if not list_db: + # check_list_permission returns None if list not found OR permission denied + # We need to check if the list exists at all to return 404 vs 403 + exists = await crud_list.get_list_by_id(db, list_id) + if not exists: + logger.warning(f"List ID {list_id} not found for request by user {current_user.email}.") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List not found") + else: + logger.warning(f"Access denied: User {current_user.email} cannot access list {list_id}.") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You do not have permission to access this list") + + # list_db already has items loaded due to check_list_permission + return list_db + + +@router.put( + "/{list_id}", + response_model=ListPublic, # Return updated basic info + summary="Update List", + tags=["Lists"] +) +async def update_list( + list_id: int, + list_in: ListUpdate, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """ + Updates a list's details (name, description, is_complete). + Requires user to be the creator or a member of the list's group. + (MVP: Allows any member to update these fields). + """ + logger.info(f"User {current_user.email} attempting to update list ID: {list_id}") + list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + + if not list_db: + exists = await crud_list.get_list_by_id(db, list_id) + status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN + detail = "List not found" if not exists else "You do not have permission to update this list" + logger.warning(f"Update failed for list {list_id} by user {current_user.email}: {detail}") + raise HTTPException(status_code=status_code, detail=detail) + + # Prevent changing group_id or creator via this endpoint for simplicity + # if list_in.group_id is not None or list_in.created_by_id is not None: + # raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change group or creator via this endpoint") + + 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}.") + return updated_list + + +@router.delete( + "/{list_id}", + status_code=status.HTTP_204_NO_CONTENT, # Standard for successful DELETE with no body + summary="Delete List", + tags=["Lists"] +) +async def delete_list( + list_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """ + Deletes a list. Requires user to be the creator of the list. + (Alternatively, could allow group owner). + """ + logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}") + # Use the helper, requiring creator permission + 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: + exists = await crud_list.get_list_by_id(db, list_id) + status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN + detail = "List not found" if not exists else "Only the list creator can delete this list" + logger.warning(f"Delete failed for list {list_id} by user {current_user.email}: {detail}") + raise HTTPException(status_code=status_code, detail=detail) + + await crud_list.delete_list(db=db, list_db=list_db) + logger.info(f"List {list_id} deleted successfully by user {current_user.email}.") + # Return Response with 204 status explicitly if needed, otherwise FastAPI handles it + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get( + "/{list_id}/status", + response_model=ListStatus, + summary="Get List Status (for polling)", + tags=["Lists"] +) +async def read_list_status( + list_id: int, + db: AsyncSession = Depends(get_db), + current_user: UserModel = Depends(get_current_user), +): + """ + Retrieves the last update time for the list and its items, plus item count. + Used for polling to check if a full refresh is needed. + Requires user to have permission to view the list. + """ + # Verify user has access to the list first + list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) + if not list_db: + # Check if list exists at all for correct error code + exists = await crud_list.get_list_by_id(db, list_id) + status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN + detail = "List not found" if not exists else "You do not have permission to access this list's status" + logger.warning(f"Status check failed for list {list_id} by user {current_user.email}: {detail}") + raise HTTPException(status_code=status_code, detail=detail) + + # Fetch the status details + list_status = await crud_list.get_list_status(db=db, list_id=list_id) + if not list_status: + # Should not happen if check_list_permission passed, but handle defensively + logger.error(f"Could not retrieve status for list {list_id} even though permission check passed.") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="List status not found") + + return list_status \ No newline at end of file diff --git a/be/app/crud/item.py b/be/app/crud/item.py new file mode 100644 index 0000000..c340665 --- /dev/null +++ b/be/app/crud/item.py @@ -0,0 +1,67 @@ +# app/crud/item.py +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.future import select +from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases +from typing import Optional, List as PyList +from datetime import datetime, timezone + +from app.models import Item as ItemModel +from app.schemas.item import ItemCreate, ItemUpdate + +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.""" + db_item = ItemModel( + name=item_in.name, + quantity=item_in.quantity, + list_id=list_id, + added_by_id=user_id, + is_complete=False # Default on creation + ) + db.add(db_item) + await db.commit() + await db.refresh(db_item) + return db_item + +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.""" + result = await db.execute( + select(ItemModel) + .where(ItemModel.list_id == list_id) + .order_by(ItemModel.created_at.asc()) # Or desc() if preferred + ) + return result.scalars().all() + +async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]: + """Gets a single item by its ID.""" + result = await db.execute(select(ItemModel).where(ItemModel.id == item_id)) + return result.scalars().first() + +async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel: + """Updates an existing item record.""" + update_data = item_in.model_dump(exclude_unset=True) # Get only provided fields + + # Special handling for is_complete + if 'is_complete' in update_data: + if update_data['is_complete'] is True: + # Mark as complete: set completed_by_id if not already set + if item_db.completed_by_id is None: + update_data['completed_by_id'] = user_id + else: + # Mark as incomplete: clear completed_by_id + update_data['completed_by_id'] = None + # Ensure updated_at is refreshed (handled by onupdate in model, but explicit is fine too) + # update_data['updated_at'] = datetime.now(timezone.utc) + + for key, value in update_data.items(): + setattr(item_db, key, value) + + db.add(item_db) # Add to session to track changes + await db.commit() + await db.refresh(item_db) + return item_db + +async def delete_item(db: AsyncSession, item_db: ItemModel) -> None: + """Deletes an item record.""" + await db.delete(item_db) + await db.commit() + return None # Or return True/False \ No newline at end of file diff --git a/be/app/crud/list.py b/be/app/crud/list.py new file mode 100644 index 0000000..de81013 --- /dev/null +++ b/be/app/crud/list.py @@ -0,0 +1,151 @@ +# app/crud/list.py +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 # Use alias for delete +from typing import Optional, List as PyList # Use alias for List +from sqlalchemy import func as sql_func, desc # Import func and desc + +from app.schemas.list import ListStatus # Import the new schema +from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel +from app.schemas.list import ListCreate, ListUpdate + +async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel: + """Creates a new list record.""" + 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 # Default on creation + ) + db.add(db_list) + await db.commit() + await db.refresh(db_list) + return db_list + +async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel]: + """ + Gets all lists accessible by a user: + - Personal lists created by the user (group_id is NULL). + - Lists belonging to groups the user is a member of. + """ + # Get IDs of groups the user is a member of + group_ids_result = await db.execute( + select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id) + ) + user_group_ids = group_ids_result.scalars().all() + + # Query for lists + query = select(ListModel).where( + or_( + # Personal lists + and_(ListModel.created_by_id == user_id, ListModel.group_id == None), + # Group lists where user is a member + ListModel.group_id.in_(user_group_ids) + ) + ).order_by(ListModel.updated_at.desc()) # Order by most recently updated + + result = await db.execute(query) + return result.scalars().all() + +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.""" + query = select(ListModel).where(ListModel.id == list_id) + if load_items: + # Eager load items and their creators/completers if needed + query = query.options( + selectinload(ListModel.items) + .options( + joinedload(ItemModel.added_by_user), # Use joinedload for simple FKs + joinedload(ItemModel.completed_by_user) + ) + ) + result = await db.execute(query) + return result.scalars().first() + +async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate) -> ListModel: + """Updates an existing list record.""" + update_data = list_in.model_dump(exclude_unset=True) # Get only provided fields + for key, value in update_data.items(): + setattr(list_db, key, value) + db.add(list_db) # Add to session to track changes + await db.commit() + await db.refresh(list_db) + return list_db + +async def delete_list(db: AsyncSession, list_db: ListModel) -> None: + """Deletes a list record.""" + # Items should be deleted automatically due to cascade="all, delete-orphan" + # on List.items relationship and ondelete="CASCADE" on Item.list_id FK + await db.delete(list_db) + await db.commit() + return None # Or return True/False if needed + +# --- Helper for Permission Checks --- +async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> Optional[ListModel]: + """ + Fetches a list and verifies user permission. + + Args: + db: Database session. + list_id: The ID of the list to check. + user_id: The ID of the user requesting access. + require_creator: If True, only allows the creator access. + + Returns: + The ListModel if found and permission granted, otherwise None. + (Raising exceptions might be better handled in the endpoint). + """ + list_db = await get_list_by_id(db, list_id=list_id, load_items=True) # Load items for detail/update/delete context + if not list_db: + return None # List not found + + # Check if user is the creator + is_creator = list_db.created_by_id == user_id + + if require_creator: + return list_db if is_creator else None + + # If not requiring creator, check membership if it's a group list + if is_creator: + return list_db # Creator always has access + + if list_db.group_id: + # Check if user is member of the list's group + from app.crud.group import is_user_member # Avoid circular import at top level + is_member = await is_user_member(db, group_id=list_db.group_id, user_id=user_id) + return list_db if is_member else None + else: + # Personal list, not the creator -> no access + return None + +async def get_list_status(db: AsyncSession, list_id: int) -> Optional[ListStatus]: + """ + Gets the update timestamps and item count for a list. + Returns None if the list itself doesn't exist. + """ + # Fetch list updated_at time + list_query = select(ListModel.updated_at).where(ListModel.id == list_id) + list_result = await db.execute(list_query) + list_updated_at = list_result.scalar_one_or_none() + + if list_updated_at is None: + return None # List not found + + # Fetch the latest item update time and count for that list + item_status_query = ( + select( + sql_func.max(ItemModel.updated_at).label("latest_item_updated_at"), + sql_func.count(ItemModel.id).label("item_count") + ) + .where(ItemModel.list_id == list_id) + ) + item_result = await db.execute(item_status_query) + item_status = item_result.first() # Use first() as aggregate always returns one row + + return ListStatus( + list_updated_at=list_updated_at, + latest_item_updated_at=item_status.latest_item_updated_at if item_status else None, + item_count=item_status.item_count if item_status else 0 + ) \ No newline at end of file diff --git a/be/app/models.py b/be/app/models.py index 20fcd54..851b8c0 100644 --- a/be/app/models.py +++ b/be/app/models.py @@ -1,7 +1,7 @@ # app/models.py import enum -import secrets # For generating invite codes -from datetime import datetime, timedelta, timezone # For invite expiry +import secrets +from datetime import datetime, timedelta, timezone from sqlalchemy import ( Column, @@ -10,20 +10,20 @@ from sqlalchemy import ( DateTime, ForeignKey, Boolean, - Enum as SAEnum, # Renamed to avoid clash with Python's enum + Enum as SAEnum, UniqueConstraint, - Index, # Added for invite code index + Index, DDL, event, - delete, # Added for potential cascade delete if needed (though FK handles it) - func, # Added for func.count() - text as sa_text # For raw SQL in index definition if needed + delete, + func, + text as sa_text, + Text, # <-- Add Text for description + Numeric # <-- Add Numeric for price ) from sqlalchemy.orm import relationship -# Removed func import as it's imported above -# from sqlalchemy.sql import func # For server_default=func.now() -from .database import Base # Import Base from database setup +from .database import Base # --- Enums --- class UserRoleEnum(enum.Enum): @@ -36,25 +36,20 @@ class User(Base): id = Column(Integer, primary_key=True, index=True) email = Column(String, unique=True, index=True, nullable=False) - password_hash = Column(String, nullable=False) # Column name used in CRUD + password_hash = Column(String, nullable=False) name = Column(String, index=True, nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) # --- Relationships --- - # Groups created by this user - created_groups = relationship("Group", back_populates="creator") # Links to Group.creator + created_groups = relationship("Group", back_populates="creator") + group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") + created_invites = relationship("Invite", back_populates="creator") - # Association object for group membership (many-to-many) - group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan") # Links to UserGroup.user - - # Invites created by this user (one-to-many) - created_invites = relationship("Invite", back_populates="creator") # Links to Invite.creator - - # Optional relationships for items/lists (Add later) - # added_items = relationship("Item", foreign_keys="[Item.added_by_id]", back_populates="added_by_user") - # completed_items = relationship("Item", foreign_keys="[Item.completed_by_id]", back_populates="completed_by_user") - # expense_shares = relationship("ExpenseShare", back_populates="user") - # created_lists = relationship("List", foreign_keys="[List.created_by_id]", back_populates="creator") + # --- NEW Relationships for Lists/Items --- + created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator") # Link List.created_by_id -> User + added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user") # Link Item.added_by_id -> User + completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user") # Link Item.completed_by_id -> User + # --- End NEW Relationships --- # --- Group Model --- @@ -63,78 +58,88 @@ class Group(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String, index=True, nullable=False) - created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User table + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) # --- Relationships --- - # The user who created this group (many-to-one) - creator = relationship("User", back_populates="created_groups") # Links to User.created_groups + creator = relationship("User", back_populates="created_groups") + member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") + invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan") - # Association object for group membership (one-to-many) - member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan") # Links to UserGroup.group - - # Invites belonging to this group (one-to-many) - invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan") # Links to Invite.group - - # Lists belonging to this group (Add later) - # lists = relationship("List", back_populates="group") + # --- NEW Relationship for Lists --- + lists = relationship("List", back_populates="group", cascade="all, delete-orphan") # Link List.group_id -> Group + # --- End NEW Relationship --- -# --- UserGroup Association Model (Many-to-Many link) --- +# --- UserGroup Association Model --- class UserGroup(Base): __tablename__ = "user_groups" - # Ensure a user cannot be in the same group twice __table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),) - id = Column(Integer, primary_key=True, index=True) # Surrogate primary key - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) # FK to User - group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group - role = Column(SAEnum(UserRoleEnum, name="userroleenum", create_type=True), nullable=False, default=UserRoleEnum.member) # Use Enum, ensure type is created + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) + role = Column(SAEnum(UserRoleEnum, name="userroleenum", create_type=True), nullable=False, default=UserRoleEnum.member) joined_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - # --- Relationships --- - # Link back to User (many-to-one from the perspective of this table row) - user = relationship("User", back_populates="group_associations") # Links to User.group_associations - - # Link back to Group (many-to-one from the perspective of this table row) - group = relationship("Group", back_populates="member_associations") # Links to Group.member_associations + user = relationship("User", back_populates="group_associations") + group = relationship("Group", back_populates="member_associations") # --- Invite Model --- class Invite(Base): __tablename__ = "invites" - # Ensure unique codes *within active invites* using a partial index (PostgreSQL specific) - # If not using PostgreSQL or need simpler logic, a simple unique=True on 'code' works, - # but doesn't allow reusing old codes once deactivated. __table_args__ = ( - Index( - 'ix_invites_active_code', - 'code', - unique=True, - postgresql_where=sa_text('is_active = true') # Partial index condition - ), + Index('ix_invites_active_code', 'code', unique=True, postgresql_where=sa_text('is_active = true')), ) id = Column(Integer, primary_key=True, index=True) - # Generate a secure random code by default - code = Column(String, unique=False, index=True, nullable=False, default=lambda: secrets.token_urlsafe(16)) # Index helps lookup, uniqueness handled by partial index - group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) # FK to Group - created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # FK to User + code = Column(String, unique=False, index=True, nullable=False, default=lambda: secrets.token_urlsafe(16)) + group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=False) + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) - # Set default expiry (e.g., 7 days from creation) expires_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc) + timedelta(days=7)) - is_active = Column(Boolean, default=True, nullable=False) # To mark as used/invalid + is_active = Column(Boolean, default=True, nullable=False) + + group = relationship("Group", back_populates="invites") + creator = relationship("User", back_populates="created_invites") + + +# === NEW: List Model === +class List(Base): + __tablename__ = "lists" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, index=True, nullable=False) + description = Column(Text, nullable=True) + created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who created this list + group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # Which group it belongs to (NULL if personal) + is_complete = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) # --- Relationships --- - # Link back to the Group this invite is for (many-to-one) - group = relationship("Group", back_populates="invites") # Links to Group.invites - - # Link back to the User who created the invite (many-to-one) - creator = relationship("User", back_populates="created_invites") # Links to User.created_invites + creator = relationship("User", back_populates="created_lists") # Link to User.created_lists + group = relationship("Group", back_populates="lists") # Link to Group.lists + items = relationship("Item", back_populates="list", cascade="all, delete-orphan", order_by="Item.created_at") # Link to Item.list, cascade deletes -# --- Models for Lists, Items, Expenses (Add later) --- -# class List(Base): ... -# class Item(Base): ... -# class Expense(Base): ... -# class ExpenseShare(Base): ... \ No newline at end of file +# === NEW: Item Model === +class Item(Base): + __tablename__ = "items" + + id = Column(Integer, primary_key=True, index=True) + list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False) # Belongs to which list + name = Column(String, index=True, nullable=False) + quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch") + is_complete = Column(Boolean, default=False, nullable=False) + price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99) + added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item + completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + # --- Relationships --- + list = relationship("List", back_populates="items") # Link to List.items + added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items") # Link to User.added_items + completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items \ No newline at end of file diff --git a/be/app/schemas/item.py b/be/app/schemas/item.py new file mode 100644 index 0000000..1f281d0 --- /dev/null +++ b/be/app/schemas/item.py @@ -0,0 +1,34 @@ +# app/schemas/item.py +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import Optional +from decimal import Decimal + +# Properties to return to client +class ItemPublic(BaseModel): + id: int + list_id: int + name: str + quantity: Optional[str] = None + is_complete: bool + price: Optional[Decimal] = None + added_by_id: int + completed_by_id: Optional[int] = None + created_at: datetime + updated_at: datetime + model_config = ConfigDict(from_attributes=True) + +# Properties to receive via API on creation +class ItemCreate(BaseModel): + name: str + quantity: Optional[str] = None + # list_id will be from path param + # added_by_id will be from current_user + +# Properties to receive via API on update +class ItemUpdate(BaseModel): + name: Optional[str] = None + quantity: Optional[str] = None + is_complete: Optional[bool] = None + price: Optional[Decimal] = None # Price added here for update + # completed_by_id will be set internally if is_complete is true \ No newline at end of file diff --git a/be/app/schemas/list.py b/be/app/schemas/list.py new file mode 100644 index 0000000..66006ca --- /dev/null +++ b/be/app/schemas/list.py @@ -0,0 +1,45 @@ +# app/schemas/list.py +from pydantic import BaseModel, ConfigDict +from datetime import datetime +from typing import Optional, List + +from .item import ItemPublic # Import item schema for nesting + +# Properties to receive via API on creation +class ListCreate(BaseModel): + name: str + description: Optional[str] = None + group_id: Optional[int] = None # Optional for sharing + +# Properties to receive via API on update +class ListUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + is_complete: Optional[bool] = None + # Potentially add group_id update later if needed + +# Base properties returned by API (common fields) +class ListBase(BaseModel): + id: int + name: str + description: Optional[str] = None + created_by_id: int + group_id: Optional[int] = None + is_complete: bool + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + +# Properties returned when listing lists (no items) +class ListPublic(ListBase): + pass # Inherits all from ListBase + +# Properties returned for a single list detail (includes items) +class ListDetail(ListBase): + items: List[ItemPublic] = [] # Include list of items + +class ListStatus(BaseModel): + list_updated_at: datetime + latest_item_updated_at: Optional[datetime] = None # Can be null if list has no items + item_count: int \ No newline at end of file diff --git a/fe/package-lock.json b/fe/package-lock.json index 0fa91d6..a0137bb 100644 --- a/fe/package-lock.json +++ b/fe/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "fe", "version": "0.0.1", + "dependencies": { + "idb": "^8.0.2" + }, "devDependencies": { "@sveltejs/adapter-node": "^5.2.11", "@sveltejs/kit": "^2.16.0", @@ -1527,6 +1530,12 @@ "node": ">= 0.4" } }, + "node_modules/idb": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/idb/-/idb-8.0.2.tgz", + "integrity": "sha512-CX70rYhx7GDDQzwwQMDwF6kDRQi5vVs6khHUumDrMecBylKkwvZ8HWvKV08AGb7VbpoGCWUQ4aHzNDgoUiOIUg==", + "license": "ISC" + }, "node_modules/import-meta-resolve": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", diff --git a/fe/package.json b/fe/package.json index 735c0ce..df26301 100644 --- a/fe/package.json +++ b/fe/package.json @@ -27,5 +27,8 @@ "tailwindcss": "^4.0.0", "typescript": "^5.0.0", "vite": "^6.0.0" + }, + "dependencies": { + "idb": "^8.0.2" } -} \ No newline at end of file +} diff --git a/fe/src/lib/components/ItemDisplay.svelte b/fe/src/lib/components/ItemDisplay.svelte new file mode 100644 index 0000000..899ae6b --- /dev/null +++ b/fe/src/lib/components/ItemDisplay.svelte @@ -0,0 +1,318 @@ + + + + +
Error Loading Data
+{loadError}
+You haven't created any lists yet.
+ {:else} +{list.description}
+ {/if} ++ ID: {list.id} | + {#if list.group_id} + Shared | + {:else} + Personal | + {/if} + Status: {list.is_complete ? 'Complete' : 'In Progress'} | Updated: {new Date( + list.updated_at + ).toLocaleString()} +
+{addItemError}
+ {/if} +This list is empty. Add items above!
+ {/if} +Loading list data...
+{/if} diff --git a/fe/src/routes/(app)/lists/[listId]/+page.ts b/fe/src/routes/(app)/lists/[listId]/+page.ts new file mode 100644 index 0000000..f0f7f34 --- /dev/null +++ b/fe/src/routes/(app)/lists/[listId]/+page.ts @@ -0,0 +1,53 @@ +// src/routes/(app)/lists/[listId]/+page.ts +import { error } from '@sveltejs/kit'; +import { apiClient, ApiClientError } from '$lib/apiClient'; +import type { ListDetail } from '$lib/schemas/list'; +// --- Use the correct generated type --- +import type { PageLoad } from './$types'; // This type includes correctly typed 'params' + +export interface ListDetailPageLoadData { + list: ListDetail; +} + +export const load: PageLoad