This commit is contained in:
mohamad 2025-05-07 20:16:16 +02:00
parent 839487567a
commit d2d484c327
15 changed files with 908 additions and 474 deletions

145
be/Untitled-1.md Normal file
View File

@ -0,0 +1,145 @@
## Polished PWA Plan: Shared Lists & Household Management
## 1. Product Overview
**Concept:**
Develop a Progressive Web App (PWA) focused on simplifying household coordination. Users can:
- Create, manage, and **share** shopping lists within defined groups (e.g., households, trip members).
- Capture images of receipts or shopping lists via the browser and extract items using **Google Cloud Vision API** for OCR.
- Track item costs on shared lists and easily split expenses among group participants.
- (Future) Manage and assign household chores.
**Target Audience:** Households, roommates, families, groups organizing shared purchases.
**UX Philosophy:**
- **User-Centered & Collaborative:** Design intuitive flows for both individual use and group collaboration with minimal friction.
- **Native-like PWA Experience:** Leverage service workers, caching, and manifest files for reliable offline use, installability, and smooth performance.
- **Clarity & Accessibility:** Prioritize high contrast, legible typography, straightforward navigation, and adherence to accessibility standards (WCAG).
- **Informative Feedback:** Provide clear visual feedback for actions (animations, loading states), OCR processing status, and data synchronization, including handling potential offline conflicts gracefully.
---
## 2. MVP Scope (Refined & Focused)
The MVP will focus on delivering a robust, shareable shopping list experience with integrated OCR and cost splitting, built as a high-quality PWA. **Chore management is deferred post-MVP** to ensure a polished core experience at launch.
1. **Shared Shopping List Management:**
* **Core Features:** Create, update, delete lists and items. Mark items as complete. Basic item sorting/reordering (e.g., manual drag-and-drop).
* **Collaboration:** Share lists within user-defined groups. Real-time (or near real-time) updates visible to group members (via polling or simple WebSocket for MVP).
* **PWA/UX:** Responsive design, offline access to cached lists, basic conflict indication if offline edits clash (e.g., "Item updated by another user, refresh needed").
2. **OCR Integration (Google Cloud Vision):**
* **Core Features:** Capture images via browser (`<input type="file" capture>` or `getUserMedia`). Upload images to the FastAPI backend. Backend securely calls **Google Cloud Vision API (Text Detection / Document Text Detection)**. Process results, suggest items to add to the list.
* **PWA/UX:** Clear instructions for image capture. Progress indicators during upload/processing. Display editable OCR results for user review and confirmation before adding to the list. Handle potential API errors or low-confidence results gracefully.
3. **Cost Splitting (Integrated with Lists):**
* **Core Features:** Assign prices to items *on the shopping list* as they are purchased. Add participants (from the shared group) to a list's expense split. Calculate totals per list and simple equal splits per participant.
* **PWA/UX:** Clear display of totals and individual shares. Easy interface for marking items as bought and adding their price.
4. **User Authentication & Group Management:**
* **Core Features:** Secure email/password signup & login (JWT-based). Ability to create simple groups (e.g., "Household"). Mechanism to invite/add users to a group (e.g., unique invite code/link). Basic role distinction (e.g., group owner/admin, member) if necessary for managing participants.
* **PWA/UX:** Minimalist forms, clear inline validation, smooth onboarding explaining the group concept.
5. **Core PWA Functionality:**
* **Core Features:** Installable via `manifest.json`. Offline access via service worker caching (app shell, static assets, user data). Basic background sync strategy for offline actions (e.g., "last write wins" for simple edits, potentially queueing adds/deletes).
---
## 3. Feature Breakdown & UX Enhancements (MVP Focus)
### A. Shared Shopping Lists
- **Screens:** Dashboard (list overview), List Detail (items), Group Management.
- **Flows:** Create list -> (Optional) Share with group -> Add/edit/check items -> See updates from others -> Mark list complete.
- **UX Focus:** Smooth transitions, clear indication of shared status, offline caching, simple conflict notification (not full resolution in MVP).
### B. OCR with Google Cloud Vision
- **Flow:** Tap "Add via OCR" -> Capture/Select Image -> Upload -> Show Progress -> Display Review Screen (editable text boxes for potential items) -> User confirms/edits -> Items added to list.
- **UX Focus:** Clear instructions, robust error handling (API errors, poor image quality feedback if possible), easy correction interface, manage user expectations regarding OCR accuracy. Monitor API costs/quotas.
### C. Integrated Cost Splitting
- **Flow:** Open shared list -> Mark item "bought" -> Input price -> View updated list total -> Go to "Split Costs" view for the list -> Confirm participants (group members) -> See calculated equal split.
- **UX Focus:** Seamless transition from shopping to cost entry. Clear, real-time calculation display. Simple participant management within the list context.
### D. User Auth & Groups
- **Flow:** Sign up/Login -> Create a group -> Invite members (e.g., share code) -> Member joins group -> Access shared lists.
- **UX Focus:** Secure and straightforward auth. Simple group creation and joining process. Clear visibility of group members.
### E. PWA Essentials
- **Manifest:** Define app name, icons, theme, display mode.
- **Service Worker:** Cache app shell, assets, API responses (user data). Implement basic offline sync queue for actions performed offline (e.g., adding/checking items). Define a clear sync conflict strategy (e.g., last-write-wins, notify user on conflict).
---
## 4. Architecture & Technology Stack
### Frontend: Svelte PWA
- **Framework:** Svelte/SvelteKit (Excellent for performant, component-based PWAs).
- **State Management:** Svelte Stores for managing UI state and cached data.
- **PWA Tools:** Workbox.js (via SvelteKit integration or standalone) for robust service worker generation and caching strategies.
- **Styling:** Tailwind CSS or standard CSS with scoped styles.
- **UX:** Design system (e.g., using Figma), Storybook for component development.
### Backend: FastAPI & PostgreSQL
- **Framework:** FastAPI (High performance, async support, auto-docs, Pydantic validation).
- **Database:** PostgreSQL (Reliable, supports JSONB for flexibility if needed). Schema designed to handle users, groups, lists, items, costs, and relationships. Basic indexing on foreign keys and frequently queried fields (user IDs, group IDs, list IDs).
- **ORM:** SQLAlchemy (async support with v2.0+) or Tortoise ORM (async-native). Alembic for migrations.
- **OCR Integration:** Use the official **Google Cloud Client Libraries for Python** to interact with the Vision API. Implement robust error handling, retries, and potentially rate limiting/cost control logic. Ensure API calls are `async` to avoid blocking.
- **Authentication:** JWT tokens for stateless session management.
- **Deployment:** Containerize using Docker/Docker Compose for development and deployment consistency. Deploy on a scalable cloud platform (e.g., Google Cloud Run, AWS Fargate, DigitalOcean App Platform).
- **Monitoring:** Logging (standard Python logging), Error Tracking (Sentry), Performance Monitoring (Prometheus/Grafana if needed later).
---
# Finalized User Stories, Flow Mapping, Sharing Model & Sync, Tech Stack & Initial Architecture Diagram
## 1. User Stories
### Authentication & User Management
- As a new user, I want to sign up with my email so I can create and manage shopping lists
- As a returning user, I want to log in securely to access my lists and groups
- As a user, I want to reset my password if I forget it
- As a user, I want to edit my profile information (name, avatar)
### Group Management
- As a user, I want to create a new group (e.g., "Household", "Roommates") to organize shared lists
- As a group creator, I want to invite others to join my group via a shareable link/code
- As an invitee, I want to easily join a group by clicking a link or entering a code
- As a group owner, I want to remove members if needed
- As a user, I want to leave a group I no longer wish to be part of
- As a user, I want to see all groups I belong to and switch between them
### List Management
- As a user, I want to create a personal shopping list with a title and optional description
- As a user, I want to share a list with a specific group so members can collaborate
- As a user, I want to view all my lists (personal and shared) from a central dashboard
- As a user, I want to archive or delete lists I no longer need
- As a user, I want to mark a list as "shopping complete" when finished
- As a user, I want to see which group a list is shared with
### Item Management
- As a user, I want to add items to a list with names and optional quantities
- As a user, I want to mark items as purchased when shopping
- As a user, I want to edit item details (name, quantity, notes)
- As a user, I want to delete items from a list
- As a user, I want to reorder items on my list for shopping efficiency
- As a user, I want to see who added or marked items as purchased in shared lists
### OCR Integration
- As a user, I want to capture a photo of a physical shopping list or receipt
- As a user, I want the app to extract text and convert it into list items
- As a user, I want to review and edit OCR results before adding to my list
- As a user, I want clear feedback on OCR processing status
- As a user, I want to retry OCR if the results aren't satisfactory
### Cost Splitting
- As a user, I want to add prices to items as I purchase them
- As a user, I want to see the total cost of all purchased items in a list
- As a user, I want to split costs equally among group members
- As a user, I want to see who owes what amount based on the split
- As a user, I want to mark expenses as settled
### PWA & Offline Experience
- As a user, I want to install the app on my home screen for quick access
- As a user, I want to view and edit my lists even when offline
- As a user, I want my changes to sync automatically when I'm back online
- As a user, I want to be notified if my offline changes conflict with others' changes

View File

@ -1,6 +1,6 @@
# app/api/v1/endpoints/auth.py # app/api/v1/endpoints/auth.py
import logging import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends
from fastapi.security import OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -9,14 +9,19 @@ from app.schemas.user import UserCreate, UserPublic
from app.schemas.auth import Token from app.schemas.auth import Token
from app.crud import user as crud_user from app.crud import user as crud_user
from app.core.security import verify_password, create_access_token from app.core.security import verify_password, create_access_token
from app.core.exceptions import (
EmailAlreadyRegisteredError,
InvalidCredentialsError,
UserCreationError
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@router.post( @router.post(
"/signup", "/signup",
response_model=UserPublic, # Return public user info, not the password hash response_model=UserPublic,
status_code=status.HTTP_201_CREATED, # Indicate resource creation status_code=201,
summary="Register New User", summary="Register New User",
description="Creates a new user account.", description="Creates a new user account.",
tags=["Authentication"] tags=["Authentication"]
@ -36,24 +41,15 @@ async def signup(
existing_user = await crud_user.get_user_by_email(db, email=user_in.email) existing_user = await crud_user.get_user_by_email(db, email=user_in.email)
if existing_user: if existing_user:
logger.warning(f"Signup failed: Email already registered - {user_in.email}") logger.warning(f"Signup failed: Email already registered - {user_in.email}")
raise HTTPException( raise EmailAlreadyRegisteredError()
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered.",
)
try: try:
created_user = await crud_user.create_user(db=db, user_in=user_in) created_user = await crud_user.create_user(db=db, user_in=user_in)
logger.info(f"User created successfully: {created_user.email} (ID: {created_user.id})") logger.info(f"User created successfully: {created_user.email} (ID: {created_user.id})")
# Note: UserPublic schema automatically excludes the hashed password
return created_user return created_user
except Exception as e: except Exception as e:
logger.error(f"Error during user creation for {user_in.email}: {e}", exc_info=True) logger.error(f"Error during user creation for {user_in.email}: {e}", exc_info=True)
raise HTTPException( raise UserCreationError()
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An error occurred during user creation.",
)
@router.post( @router.post(
"/login", "/login",
@ -63,7 +59,7 @@ async def signup(
tags=["Authentication"] tags=["Authentication"]
) )
async def login( async def login(
form_data: OAuth2PasswordRequestForm = Depends(), # Use standard form for username/password form_data: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
""" """
@ -76,16 +72,11 @@ async def login(
user = await crud_user.get_user_by_email(db, email=form_data.username) user = await crud_user.get_user_by_email(db, email=form_data.username)
# Check if user exists and password is correct # Check if user exists and password is correct
# Use the correct attribute name 'password_hash' from the User model if not user or not verify_password(form_data.password, user.password_hash):
if not user or not verify_password(form_data.password, user.password_hash): # <-- CORRECTED LINE
logger.warning(f"Login failed: Invalid credentials for user {form_data.username}") logger.warning(f"Login failed: Invalid credentials for user {form_data.username}")
raise HTTPException( raise InvalidCredentialsError()
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"}, # Standard header for 401
)
# Generate JWT # Generate JWT
access_token = create_access_token(subject=user.email) # Use email as subject access_token = create_access_token(subject=user.email)
logger.info(f"Login successful, token generated for user: {user.email}") logger.info(f"Login successful, token generated for user: {user.email}")
return Token(access_token=access_token, token_type="bearer") return Token(access_token=access_token, token_type="bearer")

View File

@ -13,6 +13,14 @@ from app.schemas.invite import InviteCodePublic
from app.schemas.message import Message # For simple responses from app.schemas.message import Message # For simple responses
from app.crud import group as crud_group from app.crud import group as crud_group
from app.crud import invite as crud_invite from app.crud import invite as crud_invite
from app.core.exceptions import (
GroupNotFoundError,
GroupPermissionError,
GroupMembershipError,
GroupOperationError,
GroupValidationError,
InviteCreationError
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -70,18 +78,14 @@ async def read_group(
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id) is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
if not is_member: if not is_member:
logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}") logger.warning(f"Access denied: User {current_user.email} not member of group {group_id}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member of this group") raise GroupMembershipError(group_id, "view group details")
group = await crud_group.get_group_by_id(db=db, group_id=group_id) group = await crud_group.get_group_by_id(db=db, group_id=group_id)
if not group: if not group:
logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)") logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") raise GroupNotFoundError(group_id)
# Manually construct the members list with UserPublic schema if needed return group
# Pydantic v2's from_attributes should handle this if relationships are loaded
# members_public = [UserPublic.model_validate(assoc.user) for assoc in group.member_associations]
# return GroupPublic.model_validate(group, update={"members": members_public})
return group # Rely on Pydantic conversion and eager loading
@router.post( @router.post(
@ -102,19 +106,19 @@ async def create_group_invite(
# --- Permission Check (MVP: Owner only) --- # --- Permission Check (MVP: Owner only) ---
if user_role != UserRoleEnum.owner: if user_role != UserRoleEnum.owner:
logger.warning(f"Permission denied: User {current_user.email} (role: {user_role}) cannot create invite for group {group_id}") logger.warning(f"Permission denied: User {current_user.email} (role: {user_role}) cannot create invite for group {group_id}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owners can create invites") raise GroupPermissionError(group_id, "create invites")
# Check if group exists (implicitly done by role check, but good practice) # Check if group exists (implicitly done by role check, but good practice)
group = await crud_group.get_group_by_id(db, group_id) group = await crud_group.get_group_by_id(db, group_id)
if not group: if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Group not found") raise GroupNotFoundError(group_id)
invite = await crud_invite.create_invite(db=db, group_id=group_id, creator_id=current_user.id) invite = await crud_invite.create_invite(db=db, group_id=group_id, creator_id=current_user.id)
if not invite: if not invite:
logger.error(f"Failed to generate unique invite code for group {group_id}") logger.error(f"Failed to generate unique invite code for group {group_id}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not generate invite code") raise InviteCreationError(group_id)
logger.info(f"Invite code created for group {group_id} by user {current_user.email}") logger.info(f"User {current_user.email} created invite code for group {group_id}")
return invite return invite
@router.delete( @router.delete(
@ -133,7 +137,7 @@ async def leave_group(
user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id) user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
if user_role is None: if user_role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="You are not a member of this group") raise GroupMembershipError(group_id, "leave (you are not a member)")
# --- MVP: Prevent owner leaving if they are the last member/owner --- # --- MVP: Prevent owner leaving if they are the last member/owner ---
if user_role == UserRoleEnum.owner: if user_role == UserRoleEnum.owner:
@ -141,7 +145,7 @@ async def leave_group(
# More robust check: count owners. For now, just check member count. # More robust check: count owners. For now, just check member count.
if member_count <= 1: if member_count <= 1:
logger.warning(f"Owner {current_user.email} attempted to leave group {group_id} as last member.") logger.warning(f"Owner {current_user.email} attempted to leave group {group_id} as last member.")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Owner cannot leave the group as the last member. Delete the group or transfer ownership.") raise GroupValidationError("Owner cannot leave the group as the last member. Delete the group or transfer ownership.")
# Proceed with removal # Proceed with removal
deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id) deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id)
@ -149,7 +153,7 @@ async def leave_group(
if not deleted: if not deleted:
# Should not happen if role check passed, but handle defensively # Should not happen if role check passed, but handle defensively
logger.error(f"Failed to remove user {current_user.email} from group {group_id} despite being a member.") logger.error(f"Failed to remove user {current_user.email} from group {group_id} despite being a member.")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to leave group") raise GroupOperationError("Failed to leave group")
logger.info(f"User {current_user.email} successfully left group {group_id}") logger.info(f"User {current_user.email} successfully left group {group_id}")
return Message(detail="Successfully left the group") return Message(detail="Successfully left the group")
@ -174,23 +178,23 @@ async def remove_group_member(
# --- Permission Check --- # --- Permission Check ---
if owner_role != UserRoleEnum.owner: if owner_role != UserRoleEnum.owner:
logger.warning(f"Permission denied: User {current_user.email} (role: {owner_role}) cannot remove members from group {group_id}") logger.warning(f"Permission denied: User {current_user.email} (role: {owner_role}) cannot remove members from group {group_id}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only group owners can remove members") raise GroupPermissionError(group_id, "remove members")
# Prevent owner removing themselves via this endpoint # Prevent owner removing themselves via this endpoint
if current_user.id == user_id_to_remove: if current_user.id == user_id_to_remove:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Owner cannot remove themselves using this endpoint. Use 'Leave Group' instead.") raise GroupValidationError("Owner cannot remove themselves using this endpoint. Use 'Leave Group' instead.")
# Check if target user is actually in the group # Check if target user is actually in the group
target_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=user_id_to_remove) target_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=user_id_to_remove)
if target_role is None: if target_role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User to remove is not a member of this group") raise GroupMembershipError(group_id, "remove this user (they are not a member)")
# Proceed with removal # Proceed with removal
deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=user_id_to_remove) deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=user_id_to_remove)
if not deleted: if not deleted:
logger.error(f"Owner {current_user.email} failed to remove user {user_id_to_remove} from group {group_id}.") logger.error(f"Owner {current_user.email} failed to remove user {user_id_to_remove} from group {group_id}.")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to remove member") raise GroupOperationError("Failed to remove member")
logger.info(f"Owner {current_user.email} successfully removed user {user_id_to_remove} from group {group_id}") logger.info(f"Owner {current_user.email} successfully removed user {user_id_to_remove} from group {group_id}")
return Message(detail="Successfully removed member from the group") return Message(detail="Successfully removed member from the group")

View File

@ -1,11 +1,12 @@
# app/api/v1/endpoints/health.py # app/api/v1/endpoints/health.py
import logging import logging
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import text from sqlalchemy.sql import text
from app.database import get_db # Import the dependency function from app.database import get_db
from app.schemas.health import HealthStatus # Import the response schema from app.schemas.health import HealthStatus
from app.core.exceptions import DatabaseConnectionError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -15,7 +16,7 @@ router = APIRouter()
response_model=HealthStatus, response_model=HealthStatus,
summary="Perform a Health Check", summary="Perform a Health Check",
description="Checks the operational status of the API and its connection to the database.", description="Checks the operational status of the API and its connection to the database.",
tags=["Health"] # Group this endpoint in Swagger UI tags=["Health"]
) )
async def check_health(db: AsyncSession = Depends(get_db)): async def check_health(db: AsyncSession = Depends(get_db)):
""" """
@ -30,16 +31,8 @@ async def check_health(db: AsyncSession = Depends(get_db)):
else: else:
# This case should ideally not happen with 'SELECT 1' # This case should ideally not happen with 'SELECT 1'
logger.error("Health check failed: Database connection check returned unexpected result.") logger.error("Health check failed: Database connection check returned unexpected result.")
# Raise 503 Service Unavailable raise DatabaseConnectionError("Unexpected result from database connection check")
raise HTTPException(
status_code=503,
detail="Database connection error: Unexpected result"
)
except Exception as e: except Exception as e:
logger.error(f"Health check failed: Database connection error - {e}", exc_info=True) # Log stack trace logger.error(f"Health check failed: Database connection error - {e}", exc_info=True)
# Raise 503 Service Unavailable raise DatabaseConnectionError(str(e))
raise HTTPException(
status_code=503,
detail=f"Database connection error: {e}"
)

View File

@ -10,6 +10,14 @@ from app.schemas.invite import InviteAccept
from app.schemas.message import Message from app.schemas.message import Message
from app.crud import invite as crud_invite from app.crud import invite as crud_invite
from app.crud import group as crud_group from app.crud import group as crud_group
from app.core.exceptions import (
InviteNotFoundError,
InviteExpiredError,
InviteAlreadyUsedError,
InviteCreationError,
GroupNotFoundError,
GroupMembershipError
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -25,35 +33,42 @@ async def accept_invite(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user), current_user: UserModel = Depends(get_current_user),
): ):
"""Allows an authenticated user to accept an invite using its code.""" """Accepts a group invite using the provided invite code."""
code = invite_in.code logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.invite_code}")
logger.info(f"User {current_user.email} attempting to accept invite code: {code}")
# Find the active, non-expired invite # Get the invite
invite = await crud_invite.get_active_invite_by_code(db=db, code=code) invite = await crud_invite.get_invite_by_code(db, invite_code=invite_in.invite_code)
if not invite: if not invite:
logger.warning(f"Invite code '{code}' not found, expired, or already used.") logger.warning(f"Invalid invite code attempted by user {current_user.email}: {invite_in.invite_code}")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite code is invalid or expired") raise InviteNotFoundError(invite_in.invite_code)
group_id = invite.group_id # Check if invite is expired
if invite.is_expired():
logger.warning(f"Expired invite code attempted by user {current_user.email}: {invite_in.invite_code}")
raise InviteExpiredError(invite_in.invite_code)
# Check if user is already in the group # Check if invite has already been used
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id) if invite.used_at:
logger.warning(f"Already used invite code attempted by user {current_user.email}: {invite_in.invite_code}")
raise InviteAlreadyUsedError(invite_in.invite_code)
# Check if group still exists
group = await crud_group.get_group_by_id(db, group_id=invite.group_id)
if not group:
logger.error(f"Group {invite.group_id} not found for invite {invite_in.invite_code}")
raise GroupNotFoundError(invite.group_id)
# Check if user is already a member
is_member = await crud_group.is_user_member(db, group_id=invite.group_id, user_id=current_user.id)
if is_member: if is_member:
logger.info(f"User {current_user.email} is already a member of group {group_id}. Invite '{code}' still deactivated.") logger.warning(f"User {current_user.email} already a member of group {invite.group_id}")
# Deactivate invite even if already member, to prevent reuse raise GroupMembershipError(invite.group_id, "join (already a member)")
await crud_invite.deactivate_invite(db=db, invite=invite)
return Message(detail="You are already a member of this group.")
# Add user to the group as a member # Add user to group and mark invite as used
added = await crud_group.add_user_to_group(db=db, group_id=group_id, user_id=current_user.id, role=UserRoleEnum.member) success = await crud_invite.accept_invite(db, invite=invite, user_id=current_user.id)
if not added: if not success:
# Should not happen if is_member check was correct, but handle defensively logger.error(f"Failed to accept invite {invite_in.invite_code} for user {current_user.email}")
logger.error(f"Failed to add user {current_user.email} to group {group_id} via invite '{code}' despite not being a member.") raise InviteCreationError(invite.group_id)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not join group.")
# Deactivate the invite (single-use) logger.info(f"User {current_user.email} successfully joined group {invite.group_id} via invite {invite_in.invite_code}")
await crud_invite.deactivate_invite(db=db, invite=invite) return Message(detail="Successfully joined the group")
logger.info(f"User {current_user.email} successfully joined group {group_id} using invite '{code}'.")
return Message(detail="Successfully joined the group.")

View File

@ -14,6 +14,7 @@ from app.models import Item as ItemModel # <-- IMPORT Item and alias it
from app.schemas.item import ItemCreate, ItemUpdate, ItemPublic from app.schemas.item import ItemCreate, ItemUpdate, ItemPublic
from app.crud import item as crud_item from app.crud import item as crud_item
from app.crud import list as crud_list from app.crud import list as crud_list
from app.core.exceptions import ItemNotFoundError, ListPermissionError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -24,17 +25,19 @@ async def get_item_and_verify_access(
item_id: int, item_id: int,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user) current_user: UserModel = Depends(get_current_user)
) -> ItemModel: # Now this type hint is valid ) -> ItemModel:
"""Dependency to get an item and verify the user has access to its list."""
item_db = await crud_item.get_item_by_id(db, item_id=item_id) item_db = await crud_item.get_item_by_id(db, item_id=item_id)
if not item_db: if not item_db:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") raise ItemNotFoundError(item_id)
# Check permission on the parent list # 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) try:
if not list_db: await crud_list.check_list_permission(db=db, list_id=item_db.list_id, user_id=current_user.id)
# User doesn't have access to the list this item belongs to except ListPermissionError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to access this item's list") # Re-raise with a more specific message
return item_db # Return the fetched item if authorized raise ListPermissionError(item_db.list_id, "access this item's list")
return item_db
# --- Endpoints --- # --- Endpoints ---
@ -55,14 +58,11 @@ async def create_list_item(
"""Adds a new item to a specific list. User must have access to the list.""" """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}") logger.info(f"User {current_user.email} adding item to list {list_id}: {item_in.name}")
# Verify user has access to the target list # 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) try:
if not list_db: await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
# Check if list exists at all for correct error code except ListPermissionError as e:
exists = await crud_list.get_list_by_id(db, list_id) # Re-raise with a more specific message
status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN raise ListPermissionError(list_id, "add items to this list")
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( created_item = await crud_item.create_item(
db=db, item_in=item_in, list_id=list_id, user_id=current_user.id db=db, item_in=item_in, list_id=list_id, user_id=current_user.id
@ -86,13 +86,11 @@ async def read_list_items(
"""Retrieves all items for a specific list if the user has access.""" """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}") logger.info(f"User {current_user.email} listing items for list {list_id}")
# Verify user has access to the list # 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) try:
if not list_db: await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
exists = await crud_list.get_list_by_id(db, list_id) except ListPermissionError as e:
status_code = status.HTTP_404_NOT_FOUND if not exists else status.HTTP_403_FORBIDDEN # Re-raise with a more specific message
detail = "List not found" if not exists else "You do not have permission to view items in this list" raise ListPermissionError(list_id, "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) items = await crud_item.get_items_by_list_id(db=db, list_id=list_id)
return items return items

View File

@ -13,6 +13,12 @@ from app.schemas.message import Message # For simple responses
from app.crud import list as crud_list from app.crud import list as crud_list
from app.crud import group as crud_group # Need for group membership check from app.crud import group as crud_group # Need for group membership check
from app.schemas.list import ListStatus from app.schemas.list import ListStatus
from app.core.exceptions import (
GroupMembershipError,
ListNotFoundError,
ListPermissionError,
ListStatusNotFoundError
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
@ -42,10 +48,7 @@ async def create_list(
is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id) is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id)
if not is_member: if not is_member:
logger.warning(f"User {current_user.email} attempted to create list in group {group_id} but is not a member.") logger.warning(f"User {current_user.email} attempted to create list in group {group_id} but is not a member.")
raise HTTPException( raise GroupMembershipError(group_id, "create lists")
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) 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}.") logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.")
@ -89,21 +92,8 @@ async def read_list(
if the user has permission (creator or group member). if the user has permission (creator or group member).
""" """
logger.info(f"User {current_user.email} requesting details for list ID: {list_id}") logger.info(f"User {current_user.email} requesting details for list ID: {list_id}")
# Use the helper to fetch and check permission simultaneously # The check_list_permission function will raise appropriate exceptions
list_db = await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id) 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 return list_db
@ -127,13 +117,6 @@ async def update_list(
logger.info(f"User {current_user.email} attempting to update list ID: {list_id}") 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) 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 # 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: # 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") # raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot change group or creator via this endpoint")
@ -161,24 +144,15 @@ async def delete_list(
logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}") logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}")
# Use the helper, requiring creator permission # 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) 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) await crud_list.delete_list(db=db, list_db=list_db)
logger.info(f"List {list_id} deleted successfully by user {current_user.email}.") 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) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get( @router.get(
"/{list_id}/status", "/{list_id}/status",
response_model=ListStatus, response_model=ListStatus,
summary="Get List Status (for polling)", summary="Get List Status",
tags=["Lists"] tags=["Lists"]
) )
async def read_list_status( async def read_list_status(
@ -196,16 +170,15 @@ async def read_list_status(
if not list_db: if not list_db:
# Check if list exists at all for correct error code # Check if list exists at all for correct error code
exists = await crud_list.get_list_by_id(db, list_id) 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 if not exists:
detail = "List not found" if not exists else "You do not have permission to access this list's status" raise ListNotFoundError(list_id)
logger.warning(f"Status check failed for list {list_id} by user {current_user.email}: {detail}") raise ListPermissionError(list_id, "access this list's status")
raise HTTPException(status_code=status_code, detail=detail)
# Fetch the status details # Fetch the status details
list_status = await crud_list.get_list_status(db=db, list_id=list_id) list_status = await crud_list.get_list_status(db=db, list_id=list_id)
if not list_status: if not list_status:
# Should not happen if check_list_permission passed, but handle defensively # 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.") 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") raise ListStatusNotFoundError(list_id)
return list_status return list_status

View File

@ -2,20 +2,27 @@
import logging import logging
from typing import List from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File from fastapi import APIRouter, Depends, UploadFile, File
from google.api_core import exceptions as google_exceptions # Import Google API exceptions from google.api_core import exceptions as google_exceptions
from app.api.dependencies import get_current_user from app.api.dependencies import get_current_user
from app.models import User as UserModel from app.models import User as UserModel
from app.schemas.ocr import OcrExtractResponse from app.schemas.ocr import OcrExtractResponse
from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error # Import helper from app.core.gemini import extract_items_from_image_gemini, gemini_initialization_error
from app.core.exceptions import (
OcrServiceUnavailableError,
InvalidFileTypeError,
FileTooLargeError,
OcrProcessingError,
OcrQuotaExceededError
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()
# Allowed image MIME types # Allowed image MIME types
ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"] ALLOWED_IMAGE_TYPES = ["image/jpeg", "image/png", "image/webp"]
MAX_FILE_SIZE_MB = 10 # Set a reasonable max file size MAX_FILE_SIZE_MB = 10
@router.post( @router.post(
"/extract-items", "/extract-items",
@ -25,7 +32,6 @@ MAX_FILE_SIZE_MB = 10 # Set a reasonable max file size
) )
async def ocr_extract_items( async def ocr_extract_items(
current_user: UserModel = Depends(get_current_user), current_user: UserModel = Depends(get_current_user),
# Use File(...) for better metadata handling than UploadFile directly as type hint
image_file: UploadFile = File(..., description="Image file (JPEG, PNG, WEBP) of the shopping list or receipt."), image_file: UploadFile = File(..., description="Image file (JPEG, PNG, WEBP) of the shopping list or receipt."),
): ):
""" """
@ -35,35 +41,27 @@ async def ocr_extract_items(
# Check if Gemini client initialized correctly # Check if Gemini client initialized correctly
if gemini_initialization_error: if gemini_initialization_error:
logger.error("OCR endpoint called but Gemini client failed to initialize.") logger.error("OCR endpoint called but Gemini client failed to initialize.")
raise HTTPException( raise OcrServiceUnavailableError(gemini_initialization_error)
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"OCR service unavailable: {gemini_initialization_error}"
)
logger.info(f"User {current_user.email} uploading image '{image_file.filename}' for OCR extraction.") logger.info(f"User {current_user.email} uploading image '{image_file.filename}' for OCR extraction.")
# --- File Validation --- # --- File Validation ---
if image_file.content_type not in ALLOWED_IMAGE_TYPES: if image_file.content_type not in ALLOWED_IMAGE_TYPES:
logger.warning(f"Invalid file type uploaded by {current_user.email}: {image_file.content_type}") logger.warning(f"Invalid file type uploaded by {current_user.email}: {image_file.content_type}")
raise HTTPException( raise InvalidFileTypeError(ALLOWED_IMAGE_TYPES)
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed types: {', '.join(ALLOWED_IMAGE_TYPES)}",
)
# Simple size check (FastAPI/Starlette might handle larger limits via config) # Simple size check
# Read content first to get size accurately
contents = await image_file.read() contents = await image_file.read()
if len(contents) > MAX_FILE_SIZE_MB * 1024 * 1024: if len(contents) > MAX_FILE_SIZE_MB * 1024 * 1024:
logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes") logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes")
raise HTTPException( raise FileTooLargeError(MAX_FILE_SIZE_MB)
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File size exceeds limit of {MAX_FILE_SIZE_MB} MB.",
)
# --- End File Validation ---
try: try:
# Call the Gemini helper function # Call the Gemini helper function
extracted_items = await extract_items_from_image_gemini(image_bytes=contents) extracted_items = await extract_items_from_image_gemini(
image_bytes=contents,
mime_type=image_file.content_type
)
logger.info(f"Successfully extracted {len(extracted_items)} items for user {current_user.email}.") logger.info(f"Successfully extracted {len(extracted_items)} items for user {current_user.email}.")
return OcrExtractResponse(extracted_items=extracted_items) return OcrExtractResponse(extracted_items=extracted_items)
@ -71,38 +69,28 @@ async def ocr_extract_items(
except ValueError as e: except ValueError as e:
# Handle errors from Gemini processing (blocked, empty response, etc.) # Handle errors from Gemini processing (blocked, empty response, etc.)
logger.warning(f"Gemini processing error for user {current_user.email}: {e}") logger.warning(f"Gemini processing error for user {current_user.email}: {e}")
raise HTTPException( raise OcrProcessingError(str(e))
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, # Or 400 Bad Request?
detail=f"Could not extract items from image: {e}",
)
except google_exceptions.ResourceExhausted as e: except google_exceptions.ResourceExhausted as e:
# Specific handling for quota errors # Specific handling for quota errors
logger.error(f"Gemini Quota Exceeded for user {current_user.email}: {e}", exc_info=True) logger.error(f"Gemini Quota Exceeded for user {current_user.email}: {e}", exc_info=True)
raise HTTPException( raise OcrQuotaExceededError()
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="OCR service quota exceeded. Please try again later.",
)
except google_exceptions.GoogleAPIError as e: except google_exceptions.GoogleAPIError as e:
# Handle other Google API errors (e.g., invalid key, permissions) # Handle other Google API errors (e.g., invalid key, permissions)
logger.error(f"Gemini API Error for user {current_user.email}: {e}", exc_info=True) logger.error(f"Gemini API Error for user {current_user.email}: {e}", exc_info=True)
raise HTTPException( raise OcrServiceUnavailableError(str(e))
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"OCR service error: {e}",
)
except RuntimeError as e: except RuntimeError as e:
# Catch initialization errors from get_gemini_client() # Catch initialization errors from get_gemini_client()
logger.error(f"Gemini client runtime error during OCR request: {e}") logger.error(f"Gemini client runtime error during OCR request: {e}")
raise HTTPException( raise OcrServiceUnavailableError(f"OCR service configuration error: {e}")
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"OCR service configuration error: {e}"
)
except Exception as e: except Exception as e:
# Catch any other unexpected errors # Catch any other unexpected errors
logger.exception(f"Unexpected error during OCR extraction for user {current_user.email}: {e}") logger.exception(f"Unexpected error during OCR extraction for user {current_user.email}: {e}")
raise HTTPException( raise OcrServiceUnavailableError("An unexpected error occurred during item extraction.")
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred during item extraction.",
)
finally: finally:
# Ensure file handle is closed (UploadFile uses SpooledTemporaryFile) # Ensure file handle is closed
await image_file.close() await image_file.close()

View File

@ -3,6 +3,7 @@ import os
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
from dotenv import load_dotenv from dotenv import load_dotenv
import logging import logging
import secrets
load_dotenv() load_dotenv()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -12,8 +13,7 @@ class Settings(BaseSettings):
GEMINI_API_KEY: str | None = None GEMINI_API_KEY: str | None = None
# --- JWT Settings --- # --- JWT Settings ---
# Generate a strong secret key using: openssl rand -hex 32 SECRET_KEY: str # Must be set via environment variable
SECRET_KEY: str = "a_very_insecure_default_secret_key_replace_me" # !! MUST BE CHANGED IN PRODUCTION !!
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Default token lifetime: 30 minutes ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # Default token lifetime: 30 minutes
@ -26,23 +26,18 @@ settings = Settings()
# Validation for critical settings # Validation for critical settings
if settings.DATABASE_URL is None: if settings.DATABASE_URL is None:
print("Warning: DATABASE_URL environment variable not set.") raise ValueError("DATABASE_URL environment variable must be set.")
# raise ValueError("DATABASE_URL environment variable not set.")
# CRITICAL: Check if the default secret key is being used # Enforce secure secret key
if settings.SECRET_KEY == "a_very_insecure_default_secret_key_replace_me": if not settings.SECRET_KEY:
print("*" * 80) raise ValueError("SECRET_KEY environment variable must be set. Generate a secure key using: openssl rand -hex 32")
print("WARNING: Using default insecure SECRET_KEY. Please generate a strong key and set it in the environment variables!")
print("Use: openssl rand -hex 32") # Validate secret key strength
print("*" * 80) if len(settings.SECRET_KEY) < 32:
# Consider raising an error in a production environment check raise ValueError("SECRET_KEY must be at least 32 characters long for security")
# if os.getenv("ENVIRONMENT") == "production":
# raise ValueError("Default SECRET_KEY is not allowed in production!")
if settings.GEMINI_API_KEY is None: if settings.GEMINI_API_KEY is None:
print.error("CRITICAL: GEMINI_API_KEY environment variable not set. Gemini features will be unavailable.") logger.error("CRITICAL: GEMINI_API_KEY environment variable not set. Gemini features will be unavailable.")
# You might raise an error here if Gemini is essential for startup
# raise ValueError("GEMINI_API_KEY must be set.")
else: else:
# Optional: Log partial key for confirmation (avoid logging full key) # Optional: Log partial key for confirmation (avoid logging full key)
logger.info(f"GEMINI_API_KEY loaded (starts with: {settings.GEMINI_API_KEY[:4]}...).") logger.info(f"GEMINI_API_KEY loaded (starts with: {settings.GEMINI_API_KEY[:4]}...).")

210
be/app/core/exceptions.py Normal file
View File

@ -0,0 +1,210 @@
from fastapi import HTTPException, status
class ListNotFoundError(HTTPException):
"""Raised when a list is not found."""
def __init__(self, list_id: int):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"List {list_id} not found"
)
class ListPermissionError(HTTPException):
"""Raised when a user doesn't have permission to access a list."""
def __init__(self, list_id: int, action: str = "access"):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"You do not have permission to {action} list {list_id}"
)
class ListCreatorRequiredError(HTTPException):
"""Raised when an action requires the list creator but the user is not the creator."""
def __init__(self, list_id: int, action: str):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Only the list creator can {action} list {list_id}"
)
class GroupNotFoundError(HTTPException):
"""Raised when a group is not found."""
def __init__(self, group_id: int):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Group {group_id} not found"
)
class GroupPermissionError(HTTPException):
"""Raised when a user doesn't have permission to perform an action in a group."""
def __init__(self, group_id: int, action: str):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"You do not have permission to {action} in group {group_id}"
)
class GroupMembershipError(HTTPException):
"""Raised when a user attempts to perform an action that requires group membership."""
def __init__(self, group_id: int, action: str = "access"):
super().__init__(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"You must be a member of group {group_id} to {action}"
)
class GroupOperationError(HTTPException):
"""Raised when a group operation fails."""
def __init__(self, detail: str):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=detail
)
class GroupValidationError(HTTPException):
"""Raised when a group operation is invalid."""
def __init__(self, detail: str):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail
)
class ItemNotFoundError(HTTPException):
"""Raised when an item is not found."""
def __init__(self, item_id: int):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Item {item_id} not found"
)
class DatabaseConnectionError(HTTPException):
"""Raised when there is an error connecting to the database."""
def __init__(self, detail: str = "Database connection error"):
super().__init__(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=detail
)
class DatabaseIntegrityError(HTTPException):
"""Raised when a database integrity constraint is violated."""
def __init__(self, detail: str = "Database integrity error"):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=detail
)
class DatabaseTransactionError(HTTPException):
"""Raised when a database transaction fails."""
def __init__(self, detail: str = "Database transaction error"):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=detail
)
class DatabaseQueryError(HTTPException):
"""Raised when a database query fails."""
def __init__(self, detail: str = "Database query error"):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=detail
)
class OcrServiceUnavailableError(HTTPException):
"""Raised when the OCR service is unavailable."""
def __init__(self, detail: str):
super().__init__(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"OCR service unavailable: {detail}"
)
class InvalidFileTypeError(HTTPException):
"""Raised when an invalid file type is uploaded for OCR."""
def __init__(self, allowed_types: list[str]):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed types: {', '.join(allowed_types)}"
)
class FileTooLargeError(HTTPException):
"""Raised when an uploaded file exceeds the size limit."""
def __init__(self, max_size_mb: int):
super().__init__(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File size exceeds limit of {max_size_mb} MB."
)
class OcrProcessingError(HTTPException):
"""Raised when there is an error processing the image with OCR."""
def __init__(self, detail: str):
super().__init__(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Could not extract items from image: {detail}"
)
class OcrQuotaExceededError(HTTPException):
"""Raised when the OCR service quota is exceeded."""
def __init__(self):
super().__init__(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="OCR service quota exceeded. Please try again later."
)
class EmailAlreadyRegisteredError(HTTPException):
"""Raised when attempting to register with an email that is already in use."""
def __init__(self):
super().__init__(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered."
)
class InvalidCredentialsError(HTTPException):
"""Raised when login credentials are invalid."""
def __init__(self):
super().__init__(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"}
)
class UserCreationError(HTTPException):
"""Raised when there is an error creating a new user."""
def __init__(self):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An error occurred during user creation."
)
class InviteNotFoundError(HTTPException):
"""Raised when an invite is not found."""
def __init__(self, invite_code: str):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Invite code {invite_code} not found"
)
class InviteExpiredError(HTTPException):
"""Raised when an invite has expired."""
def __init__(self, invite_code: str):
super().__init__(
status_code=status.HTTP_410_GONE,
detail=f"Invite code {invite_code} has expired"
)
class InviteAlreadyUsedError(HTTPException):
"""Raised when an invite has already been used."""
def __init__(self, invite_code: str):
super().__init__(
status_code=status.HTTP_410_GONE,
detail=f"Invite code {invite_code} has already been used"
)
class InviteCreationError(HTTPException):
"""Raised when an invite cannot be created."""
def __init__(self, group_id: int):
super().__init__(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create invite for group {group_id}"
)
class ListStatusNotFoundError(HTTPException):
"""Raised when a list's status cannot be retrieved."""
def __init__(self, list_id: int):
super().__init__(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Status for list {list_id} not found"
)

View File

@ -79,12 +79,13 @@ Apples
Organic Bananas Organic Bananas
""" """
async def extract_items_from_image_gemini(image_bytes: bytes) -> List[str]: async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "image/jpeg") -> List[str]:
""" """
Uses Gemini Flash to extract shopping list items from image bytes. Uses Gemini Flash to extract shopping list items from image bytes.
Args: Args:
image_bytes: The image content as bytes. image_bytes: The image content as bytes.
mime_type: The MIME type of the image (e.g., "image/jpeg", "image/png", "image/webp").
Returns: Returns:
A list of extracted item strings. A list of extracted item strings.
@ -98,7 +99,7 @@ async def extract_items_from_image_gemini(image_bytes: bytes) -> List[str]:
# Prepare image part for multimodal input # Prepare image part for multimodal input
image_part = { image_part = {
"mime_type": "image/jpeg", # Or image/png, image/webp etc. Adjust if needed or detect mime type "mime_type": mime_type,
"data": image_bytes "data": image_bytes
} }

View File

@ -2,67 +2,66 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # For eager loading members from sqlalchemy.orm import selectinload # For eager loading members
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List from typing import Optional, List
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel
from app.schemas.group import GroupCreate from app.schemas.group import GroupCreate
from app.models import UserRoleEnum # Import enum from app.models import UserRoleEnum # Import enum
from app.core.exceptions import (
# --- Keep existing functions: get_user_by_email, create_user --- GroupOperationError,
# (These are actually user CRUD, should ideally be in user.py, but keep for now if working) GroupNotFoundError,
from app.core.security import hash_password DatabaseConnectionError,
from app.schemas.user import UserCreate # Assuming create_user uses this DatabaseIntegrityError,
DatabaseQueryError,
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]: DatabaseTransactionError
result = await db.execute(select(UserModel).filter(UserModel.email == email))
return result.scalars().first()
async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
_hashed_password = hash_password(user_in.password)
db_user = UserModel(
email=user_in.email,
password_hash=_hashed_password, # Use correct keyword argument
name=user_in.name
) )
db.add(db_user)
await db.commit()
await db.refresh(db_user)
return db_user
# --- End User CRUD ---
# --- Group CRUD --- # --- Group CRUD ---
async def create_group(db: AsyncSession, group_in: GroupCreate, creator_id: int) -> GroupModel: async def create_group(db: AsyncSession, group_in: GroupCreate, creator_id: int) -> GroupModel:
"""Creates a group and adds the creator as the owner.""" """Creates a group and adds the creator as the owner."""
try:
async with db.begin():
db_group = GroupModel(name=group_in.name, created_by_id=creator_id) db_group = GroupModel(name=group_in.name, created_by_id=creator_id)
db.add(db_group) db.add(db_group)
await db.flush() # Flush to get the db_group.id for the UserGroup entry await db.flush()
# Add creator as owner
db_user_group = UserGroupModel( db_user_group = UserGroupModel(
user_id=creator_id, user_id=creator_id,
group_id=db_group.id, group_id=db_group.id,
role=UserRoleEnum.owner # Use the Enum member role=UserRoleEnum.owner
) )
db.add(db_user_group) db.add(db_user_group)
await db.flush()
await db.commit()
await db.refresh(db_group) await db.refresh(db_group)
return db_group return db_group
except IntegrityError as e:
raise DatabaseIntegrityError(f"Failed to create group: {str(e)}")
except OperationalError as e:
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseTransactionError(f"Failed to create group: {str(e)}")
async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]: async def get_user_groups(db: AsyncSession, user_id: int) -> List[GroupModel]:
"""Gets all groups a user is a member of.""" """Gets all groups a user is a member of."""
try:
async with db.begin():
result = await db.execute( result = await db.execute(
select(GroupModel) select(GroupModel)
.join(UserGroupModel) .join(UserGroupModel)
.where(UserGroupModel.user_id == user_id) .where(UserGroupModel.user_id == user_id)
.options(selectinload(GroupModel.member_associations)) # Optional: preload associations if needed often .options(selectinload(GroupModel.member_associations))
) )
return result.scalars().all() 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 groups: {str(e)}")
async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]: async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupModel]:
"""Gets a single group by its ID, optionally loading members.""" """Gets a single group by its ID, optionally loading members."""
# Use selectinload to eager load members and their user details try:
async with db.begin():
result = await db.execute( result = await db.execute(
select(GroupModel) select(GroupModel)
.where(GroupModel.id == group_id) .where(GroupModel.id == group_id)
@ -71,53 +70,86 @@ async def get_group_by_id(db: AsyncSession, group_id: int) -> Optional[GroupMode
) )
) )
return result.scalars().first() 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 group: {str(e)}")
async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool: async def is_user_member(db: AsyncSession, group_id: int, user_id: int) -> bool:
"""Checks if a user is a member of a specific group.""" """Checks if a user is a member of a specific group."""
try:
async with db.begin():
result = await db.execute( result = await db.execute(
select(UserGroupModel.id) # Select just one column for existence check select(UserGroupModel.id)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id) .where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
.limit(1) .limit(1)
) )
return result.scalar_one_or_none() is not None return result.scalar_one_or_none() is not None
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to check group membership: {str(e)}")
async def get_user_role_in_group(db: AsyncSession, group_id: int, user_id: int) -> Optional[UserRoleEnum]: async def get_user_role_in_group(db: AsyncSession, group_id: int, user_id: int) -> Optional[UserRoleEnum]:
"""Gets the role of a user in a specific group.""" """Gets the role of a user in a specific group."""
try:
async with db.begin():
result = await db.execute( result = await db.execute(
select(UserGroupModel.role) select(UserGroupModel.role)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id) .where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
) )
role = result.scalar_one_or_none() return result.scalar_one_or_none()
return role # Will be None if not a member, or the UserRoleEnum value 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 role: {str(e)}")
async def add_user_to_group(db: AsyncSession, group_id: int, user_id: int, role: UserRoleEnum = UserRoleEnum.member) -> Optional[UserGroupModel]: async def add_user_to_group(db: AsyncSession, group_id: int, user_id: int, role: UserRoleEnum = UserRoleEnum.member) -> Optional[UserGroupModel]:
"""Adds a user to a group if they aren't already a member.""" """Adds a user to a group if they aren't already a member."""
# Check if already exists try:
async with db.begin():
existing = await db.execute( existing = await db.execute(
select(UserGroupModel).where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id) select(UserGroupModel).where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
) )
if existing.scalar_one_or_none(): if existing.scalar_one_or_none():
return None # Indicate user already in group return None
db_user_group = UserGroupModel(user_id=user_id, group_id=group_id, role=role) db_user_group = UserGroupModel(user_id=user_id, group_id=group_id, role=role)
db.add(db_user_group) db.add(db_user_group)
await db.commit() await db.flush()
await db.refresh(db_user_group) await db.refresh(db_user_group)
return db_user_group return db_user_group
except IntegrityError as e:
raise DatabaseIntegrityError(f"Failed to add user to group: {str(e)}")
except OperationalError as e:
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseTransactionError(f"Failed to add user to group: {str(e)}")
async def remove_user_from_group(db: AsyncSession, group_id: int, user_id: int) -> bool: async def remove_user_from_group(db: AsyncSession, group_id: int, user_id: int) -> bool:
"""Removes a user from a group.""" """Removes a user from a group."""
try:
async with db.begin():
result = await db.execute( result = await db.execute(
delete(UserGroupModel) delete(UserGroupModel)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id) .where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
.returning(UserGroupModel.id) # Optional: check if a row was actually deleted .returning(UserGroupModel.id)
) )
await db.commit() return result.scalar_one_or_none() is not None
return result.scalar_one_or_none() is not None # True if deletion happened except OperationalError as e:
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseTransactionError(f"Failed to remove user from group: {str(e)}")
async def get_group_member_count(db: AsyncSession, group_id: int) -> int: async def get_group_member_count(db: AsyncSession, group_id: int) -> int:
"""Counts the number of members in a group.""" """Counts the number of members in a group."""
try:
async with db.begin():
result = await db.execute( result = await db.execute(
select(func.count(UserGroupModel.id)).where(UserGroupModel.group_id == group_id) select(func.count(UserGroupModel.id)).where(UserGroupModel.group_id == group_id)
) )
return result.scalar_one() return result.scalar_one()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to count group members: {str(e)}")

View File

@ -2,14 +2,24 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases 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 typing import Optional, List as PyList
from datetime import datetime, timezone from datetime import datetime, timezone
from app.models import Item as ItemModel from app.models import Item as ItemModel
from app.schemas.item import ItemCreate, ItemUpdate from app.schemas.item import ItemCreate, ItemUpdate
from app.core.exceptions import (
ItemNotFoundError,
DatabaseConnectionError,
DatabaseIntegrityError,
DatabaseQueryError,
DatabaseTransactionError
)
async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_id: int) -> ItemModel: 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.""" """Creates a new item record for a specific list."""
try:
async with db.begin():
db_item = ItemModel( db_item = ItemModel(
name=item_in.name, name=item_in.name,
quantity=item_in.quantity, quantity=item_in.quantity,
@ -18,26 +28,46 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
is_complete=False # Default on creation is_complete=False # Default on creation
) )
db.add(db_item) db.add(db_item)
await db.commit() await db.flush()
await db.refresh(db_item) await db.refresh(db_item)
return db_item return db_item
except IntegrityError as e:
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)}")
async def get_items_by_list_id(db: AsyncSession, list_id: int) -> PyList[ItemModel]: 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.""" """Gets all items belonging to a specific list, ordered by creation time."""
try:
async with db.begin():
result = await db.execute( result = await db.execute(
select(ItemModel) select(ItemModel)
.where(ItemModel.list_id == list_id) .where(ItemModel.list_id == list_id)
.order_by(ItemModel.created_at.asc()) # Or desc() if preferred .order_by(ItemModel.created_at.asc()) # Or desc() if preferred
) )
return result.scalars().all() 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]: async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
"""Gets a single item by its ID.""" """Gets a single item by its ID."""
try:
async with db.begin():
result = await db.execute(select(ItemModel).where(ItemModel.id == item_id)) result = await db.execute(select(ItemModel).where(ItemModel.id == item_id))
return result.scalars().first() 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: async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel:
"""Updates an existing item record.""" """Updates an existing item record."""
try:
async with db.begin():
update_data = item_in.model_dump(exclude_unset=True) # Get only provided fields update_data = item_in.model_dump(exclude_unset=True) # Get only provided fields
# Special handling for is_complete # Special handling for is_complete
@ -56,12 +86,23 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
setattr(item_db, key, value) setattr(item_db, key, value)
db.add(item_db) # Add to session to track changes db.add(item_db) # Add to session to track changes
await db.commit() await db.flush()
await db.refresh(item_db) await db.refresh(item_db)
return item_db return item_db
except IntegrityError as e:
raise DatabaseIntegrityError(f"Failed to update item: {str(e)}")
except OperationalError as e:
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseTransactionError(f"Failed to update item: {str(e)}")
async def delete_item(db: AsyncSession, item_db: ItemModel) -> None: async def delete_item(db: AsyncSession, item_db: ItemModel) -> None:
"""Deletes an item record.""" """Deletes an item record."""
try:
async with db.begin():
await db.delete(item_db) await db.delete(item_db)
await db.commit()
return None # Or return True/False return None # Or return True/False
except OperationalError as e:
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseTransactionError(f"Failed to delete item: {str(e)}")

View File

@ -2,138 +2,159 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy import or_, and_, delete as sql_delete # Use alias for delete from sqlalchemy import or_, and_, delete as sql_delete, func as sql_func, desc
from typing import Optional, List as PyList # Use alias for List from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from sqlalchemy import func as sql_func, desc # Import func and desc from typing import Optional, List as PyList
from app.schemas.list import ListStatus # Import the new schema from app.schemas.list import ListStatus
from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel
from app.schemas.list import ListCreate, ListUpdate from app.schemas.list import ListCreate, ListUpdate
from app.core.exceptions import (
ListNotFoundError,
ListPermissionError,
ListCreatorRequiredError,
DatabaseConnectionError,
DatabaseIntegrityError,
DatabaseQueryError,
DatabaseTransactionError
)
async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel: async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel:
"""Creates a new list record.""" """Creates a new list record."""
try:
async with db.begin():
db_list = ListModel( db_list = ListModel(
name=list_in.name, name=list_in.name,
description=list_in.description, description=list_in.description,
group_id=list_in.group_id, group_id=list_in.group_id,
created_by_id=creator_id, created_by_id=creator_id,
is_complete=False # Default on creation is_complete=False
) )
db.add(db_list) db.add(db_list)
await db.commit() await db.flush()
await db.refresh(db_list) await db.refresh(db_list)
return db_list return db_list
except IntegrityError as e:
raise DatabaseIntegrityError(f"Failed to create list: {str(e)}")
except OperationalError as e:
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseTransactionError(f"Failed to create list: {str(e)}")
async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel]: async def get_lists_for_user(db: AsyncSession, user_id: int) -> PyList[ListModel]:
""" """Gets all lists accessible by a user."""
Gets all lists accessible by a user: try:
- Personal lists created by the user (group_id is NULL). async with db.begin():
- 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( group_ids_result = await db.execute(
select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id) select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id)
) )
user_group_ids = group_ids_result.scalars().all() user_group_ids = group_ids_result.scalars().all()
# Query for lists
query = select(ListModel).where( query = select(ListModel).where(
or_( or_(
# Personal lists
and_(ListModel.created_by_id == user_id, ListModel.group_id == None), 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) ListModel.group_id.in_(user_group_ids)
) )
).order_by(ListModel.updated_at.desc()) # Order by most recently updated ).order_by(ListModel.updated_at.desc())
result = await db.execute(query) result = await db.execute(query)
return result.scalars().all() 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]: 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.""" """Gets a single list by ID, optionally loading its items."""
try:
async with db.begin():
query = select(ListModel).where(ListModel.id == list_id) query = select(ListModel).where(ListModel.id == list_id)
if load_items: if load_items:
# Eager load items and their creators/completers if needed
query = query.options( query = query.options(
selectinload(ListModel.items) selectinload(ListModel.items)
.options( .options(
joinedload(ItemModel.added_by_user), # Use joinedload for simple FKs joinedload(ItemModel.added_by_user),
joinedload(ItemModel.completed_by_user) joinedload(ItemModel.completed_by_user)
) )
) )
result = await db.execute(query) result = await db.execute(query)
return result.scalars().first() 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: async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate) -> ListModel:
"""Updates an existing list record.""" """Updates an existing list record."""
update_data = list_in.model_dump(exclude_unset=True) # Get only provided fields try:
async with db.begin():
update_data = list_in.model_dump(exclude_unset=True)
for key, value in update_data.items(): for key, value in update_data.items():
setattr(list_db, key, value) setattr(list_db, key, value)
db.add(list_db) # Add to session to track changes db.add(list_db)
await db.commit() await db.flush()
await db.refresh(list_db) await db.refresh(list_db)
return list_db return list_db
except IntegrityError as e:
raise DatabaseIntegrityError(f"Failed to update list: {str(e)}")
except OperationalError as e:
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseTransactionError(f"Failed to update list: {str(e)}")
async def delete_list(db: AsyncSession, list_db: ListModel) -> None: async def delete_list(db: AsyncSession, list_db: ListModel) -> None:
"""Deletes a list record.""" """Deletes a list record."""
# Items should be deleted automatically due to cascade="all, delete-orphan" try:
# on List.items relationship and ondelete="CASCADE" on Item.list_id FK async with db.begin():
await db.delete(list_db) await db.delete(list_db)
await db.commit() return None
return None # Or return True/False if needed except OperationalError as e:
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseTransactionError(f"Failed to delete list: {str(e)}")
# --- Helper for Permission Checks --- async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> ListModel:
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."""
""" try:
Fetches a list and verifies user permission. async with db.begin():
list_db = await get_list_by_id(db, list_id=list_id, load_items=True)
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: if not list_db:
return None # List not found raise ListNotFoundError(list_id)
# Check if user is the creator
is_creator = list_db.created_by_id == user_id is_creator = list_db.created_by_id == user_id
if require_creator: if require_creator:
return list_db if is_creator else None if not is_creator:
raise ListCreatorRequiredError(list_id, "access")
return list_db
# If not requiring creator, check membership if it's a group list
if is_creator: if is_creator:
return list_db # Creator always has access return list_db
if list_db.group_id: if list_db.group_id:
# Check if user is member of the list's group from app.crud.group import is_user_member
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) 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 if not is_member:
raise ListPermissionError(list_id)
return list_db
else: else:
# Personal list, not the creator -> no access raise ListPermissionError(list_id)
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 check list permissions: {str(e)}")
async def get_list_status(db: AsyncSession, list_id: int) -> Optional[ListStatus]: async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
""" """Gets the update timestamps and item count for a list."""
Gets the update timestamps and item count for a list. try:
Returns None if the list itself doesn't exist. async with db.begin():
"""
# Fetch list updated_at time
list_query = select(ListModel.updated_at).where(ListModel.id == list_id) list_query = select(ListModel.updated_at).where(ListModel.id == list_id)
list_result = await db.execute(list_query) list_result = await db.execute(list_query)
list_updated_at = list_result.scalar_one_or_none() list_updated_at = list_result.scalar_one_or_none()
if list_updated_at is None: if list_updated_at is None:
return None # List not found raise ListNotFoundError(list_id)
# Fetch the latest item update time and count for that list
item_status_query = ( item_status_query = (
select( select(
sql_func.max(ItemModel.updated_at).label("latest_item_updated_at"), sql_func.max(ItemModel.updated_at).label("latest_item_updated_at"),
@ -142,10 +163,14 @@ async def get_list_status(db: AsyncSession, list_id: int) -> Optional[ListStatus
.where(ItemModel.list_id == list_id) .where(ItemModel.list_id == list_id)
) )
item_result = await db.execute(item_status_query) item_result = await db.execute(item_status_query)
item_status = item_result.first() # Use first() as aggregate always returns one row item_status = item_result.first()
return ListStatus( return ListStatus(
list_updated_at=list_updated_at, list_updated_at=list_updated_at,
latest_item_updated_at=item_status.latest_item_updated_at if item_status else None, 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 item_count=item_status.item_count if item_status else 0
) )
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)}")

View File

@ -1,28 +1,51 @@
# app/crud/user.py # app/crud/user.py
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional from typing import Optional
from app.models import User as UserModel # Alias to avoid name clash from app.models import User as UserModel # Alias to avoid name clash
from app.schemas.user import UserCreate from app.schemas.user import UserCreate
from app.core.security import hash_password from app.core.security import hash_password
from app.core.exceptions import (
UserCreationError,
EmailAlreadyRegisteredError,
DatabaseConnectionError,
DatabaseIntegrityError,
DatabaseQueryError,
DatabaseTransactionError
)
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]: async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]:
"""Fetches a user from the database by email.""" """Fetches a user from the database by email."""
try:
async with db.begin():
result = await db.execute(select(UserModel).filter(UserModel.email == email)) result = await db.execute(select(UserModel).filter(UserModel.email == email))
return result.scalars().first() 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 user: {str(e)}")
async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel: async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
"""Creates a new user record in the database.""" """Creates a new user record in the database."""
_hashed_password = hash_password(user_in.password) # Keep local var name if you like try:
# Create SQLAlchemy model instance - explicitly map fields async with db.begin():
_hashed_password = hash_password(user_in.password)
db_user = UserModel( db_user = UserModel(
email=user_in.email, email=user_in.email,
# Use the correct keyword argument matching the model column name
password_hash=_hashed_password, password_hash=_hashed_password,
name=user_in.name name=user_in.name
) )
db.add(db_user) db.add(db_user)
await db.commit() await db.flush() # Flush to get DB-generated values
await db.refresh(db_user) # Refresh to get DB-generated values like ID, created_at await db.refresh(db_user)
return db_user return db_user
except IntegrityError as e:
if "unique constraint" in str(e).lower():
raise EmailAlreadyRegisteredError()
raise DatabaseIntegrityError(f"Failed to create user: {str(e)}")
except OperationalError as e:
raise DatabaseConnectionError(f"Database connection error: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseTransactionError(f"Failed to create user: {str(e)}")