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
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends
from fastapi.security import OAuth2PasswordRequestForm
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.crud import user as crud_user
from app.core.security import verify_password, create_access_token
from app.core.exceptions import (
EmailAlreadyRegisteredError,
InvalidCredentialsError,
UserCreationError
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/signup",
response_model=UserPublic, # Return public user info, not the password hash
status_code=status.HTTP_201_CREATED, # Indicate resource creation
response_model=UserPublic,
status_code=201,
summary="Register New User",
description="Creates a new user account.",
tags=["Authentication"]
@ -36,24 +41,15 @@ async def signup(
existing_user = await crud_user.get_user_by_email(db, email=user_in.email)
if existing_user:
logger.warning(f"Signup failed: Email already registered - {user_in.email}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered.",
)
raise EmailAlreadyRegisteredError()
try:
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})")
# Note: UserPublic schema automatically excludes the hashed password
return created_user
except Exception as e:
logger.error(f"Error during user creation for {user_in.email}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An error occurred during user creation.",
)
raise UserCreationError()
@router.post(
"/login",
@ -63,7 +59,7 @@ async def signup(
tags=["Authentication"]
)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(), # Use standard form for username/password
form_data: OAuth2PasswordRequestForm = Depends(),
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)
# 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): # <-- CORRECTED LINE
if not user or not verify_password(form_data.password, user.password_hash):
logger.warning(f"Login failed: Invalid credentials for user {form_data.username}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"}, # Standard header for 401
)
raise InvalidCredentialsError()
# 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}")
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.crud import group as crud_group
from app.crud import invite as crud_invite
from app.core.exceptions import (
GroupNotFoundError,
GroupPermissionError,
GroupMembershipError,
GroupOperationError,
GroupValidationError,
InviteCreationError
)
logger = logging.getLogger(__name__)
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)
if not is_member:
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)
if not group:
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")
logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)")
raise GroupNotFoundError(group_id)
# Manually construct the members list with UserPublic schema if needed
# 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
return group
@router.post(
@ -102,19 +106,19 @@ async def create_group_invite(
# --- Permission Check (MVP: Owner only) ---
if user_role != UserRoleEnum.owner:
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)
group = await crud_group.get_group_by_id(db, group_id)
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)
if not invite:
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")
logger.error(f"Failed to generate unique invite code for group {group_id}")
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
@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)
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 ---
if user_role == UserRoleEnum.owner:
@ -141,7 +145,7 @@ async def leave_group(
# More robust check: count owners. For now, just check member count.
if member_count <= 1:
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
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:
# 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.")
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}")
return Message(detail="Successfully left the group")
@ -174,23 +178,23 @@ async def remove_group_member(
# --- Permission Check ---
if owner_role != UserRoleEnum.owner:
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
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
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:
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
deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=user_id_to_remove)
if not deleted:
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}")
return Message(detail="Successfully removed member from the group")

View File

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

View File

@ -10,6 +10,14 @@ from app.schemas.invite import InviteAccept
from app.schemas.message import Message
from app.crud import invite as crud_invite
from app.crud import group as crud_group
from app.core.exceptions import (
InviteNotFoundError,
InviteExpiredError,
InviteAlreadyUsedError,
InviteCreationError,
GroupNotFoundError,
GroupMembershipError
)
logger = logging.getLogger(__name__)
router = APIRouter()
@ -25,35 +33,42 @@ async def accept_invite(
db: AsyncSession = Depends(get_db),
current_user: UserModel = Depends(get_current_user),
):
"""Allows an authenticated user to accept an invite using its code."""
code = invite_in.code
logger.info(f"User {current_user.email} attempting to accept invite code: {code}")
"""Accepts a group invite using the provided invite code."""
logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.invite_code}")
# Find the active, non-expired invite
invite = await crud_invite.get_active_invite_by_code(db=db, code=code)
# Get the invite
invite = await crud_invite.get_invite_by_code(db, invite_code=invite_in.invite_code)
if not invite:
logger.warning(f"Invite code '{code}' not found, expired, or already used.")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite code is invalid or expired")
logger.warning(f"Invalid invite code attempted by user {current_user.email}: {invite_in.invite_code}")
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
is_member = await crud_group.is_user_member(db=db, group_id=group_id, user_id=current_user.id)
# Check if invite has already been used
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:
logger.info(f"User {current_user.email} is already a member of group {group_id}. Invite '{code}' still deactivated.")
# Deactivate invite even if already member, to prevent reuse
await crud_invite.deactivate_invite(db=db, invite=invite)
return Message(detail="You are already a member of this group.")
logger.warning(f"User {current_user.email} already a member of group {invite.group_id}")
raise GroupMembershipError(invite.group_id, "join (already a member)")
# Add user to the group as a member
added = await crud_group.add_user_to_group(db=db, group_id=group_id, user_id=current_user.id, role=UserRoleEnum.member)
if not added:
# Should not happen if is_member check was correct, but handle defensively
logger.error(f"Failed to add user {current_user.email} to group {group_id} via invite '{code}' despite not being a member.")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not join group.")
# Add user to group and mark invite as used
success = await crud_invite.accept_invite(db, invite=invite, user_id=current_user.id)
if not success:
logger.error(f"Failed to accept invite {invite_in.invite_code} for user {current_user.email}")
raise InviteCreationError(invite.group_id)
# Deactivate the invite (single-use)
await crud_invite.deactivate_invite(db=db, invite=invite)
logger.info(f"User {current_user.email} successfully joined group {group_id} using invite '{code}'.")
return Message(detail="Successfully joined the group.")
logger.info(f"User {current_user.email} successfully joined group {invite.group_id} via invite {invite_in.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.crud import item as crud_item
from app.crud import list as crud_list
from app.core.exceptions import ItemNotFoundError, ListPermissionError
logger = logging.getLogger(__name__)
router = APIRouter()
@ -24,17 +25,19 @@ 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
) -> 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)
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
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
try:
await crud_list.check_list_permission(db=db, list_id=item_db.list_id, user_id=current_user.id)
except ListPermissionError as e:
# Re-raise with a more specific message
raise ListPermissionError(item_db.list_id, "access this item's list")
return item_db
# --- 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."""
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)
try:
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
except ListPermissionError as e:
# Re-raise with a more specific message
raise ListPermissionError(list_id, "add items to this list")
created_item = await crud_item.create_item(
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."""
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)
try:
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
except ListPermissionError as e:
# Re-raise with a more specific message
raise ListPermissionError(list_id, "view items in this list")
items = await crud_item.get_items_by_list_id(db=db, list_id=list_id)
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 group as crud_group # Need for group membership check
from app.schemas.list import ListStatus
from app.core.exceptions import (
GroupMembershipError,
ListNotFoundError,
ListPermissionError,
ListStatusNotFoundError
)
logger = logging.getLogger(__name__)
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)
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",
)
raise GroupMembershipError(group_id, "create lists")
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}.")
@ -89,21 +92,8 @@ async def read_list(
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
# 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)
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
@ -127,13 +117,6 @@ async def update_list(
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")
@ -161,24 +144,15 @@ async def delete_list(
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)",
summary="Get List Status",
tags=["Lists"]
)
async def read_list_status(
@ -196,16 +170,15 @@ async def read_list_status(
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)
if not exists:
raise ListNotFoundError(list_id)
raise ListPermissionError(list_id, "access this list's status")
# 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")
# 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 ListStatusNotFoundError(list_id)
return list_status

View File

@ -2,20 +2,27 @@
import logging
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from google.api_core import exceptions as google_exceptions # Import Google API exceptions
from fastapi import APIRouter, Depends, UploadFile, File
from google.api_core import exceptions as google_exceptions
from app.api.dependencies import get_current_user
from app.models import User as UserModel
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__)
router = APIRouter()
# Allowed image MIME types
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(
"/extract-items",
@ -25,7 +32,6 @@ MAX_FILE_SIZE_MB = 10 # Set a reasonable max file size
)
async def ocr_extract_items(
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."),
):
"""
@ -34,36 +40,28 @@ async def ocr_extract_items(
"""
# Check if Gemini client initialized correctly
if gemini_initialization_error:
logger.error("OCR endpoint called but Gemini client failed to initialize.")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"OCR service unavailable: {gemini_initialization_error}"
)
logger.error("OCR endpoint called but Gemini client failed to initialize.")
raise OcrServiceUnavailableError(gemini_initialization_error)
logger.info(f"User {current_user.email} uploading image '{image_file.filename}' for OCR extraction.")
# --- File Validation ---
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}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed types: {', '.join(ALLOWED_IMAGE_TYPES)}",
)
raise InvalidFileTypeError(ALLOWED_IMAGE_TYPES)
# Simple size check (FastAPI/Starlette might handle larger limits via config)
# Read content first to get size accurately
# Simple size check
contents = await image_file.read()
if len(contents) > MAX_FILE_SIZE_MB * 1024 * 1024:
logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes")
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File size exceeds limit of {MAX_FILE_SIZE_MB} MB.",
)
# --- End File Validation ---
logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes")
raise FileTooLargeError(MAX_FILE_SIZE_MB)
try:
# 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}.")
return OcrExtractResponse(extracted_items=extracted_items)
@ -71,38 +69,28 @@ async def ocr_extract_items(
except ValueError as e:
# Handle errors from Gemini processing (blocked, empty response, etc.)
logger.warning(f"Gemini processing error for user {current_user.email}: {e}")
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, # Or 400 Bad Request?
detail=f"Could not extract items from image: {e}",
)
raise OcrProcessingError(str(e))
except google_exceptions.ResourceExhausted as e:
# Specific handling for quota errors
logger.error(f"Gemini Quota Exceeded for user {current_user.email}: {e}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="OCR service quota exceeded. Please try again later.",
)
raise OcrQuotaExceededError()
except google_exceptions.GoogleAPIError as e:
# 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)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"OCR service error: {e}",
)
raise OcrServiceUnavailableError(str(e))
except RuntimeError as e:
# Catch initialization errors from get_gemini_client()
logger.error(f"Gemini client runtime error during OCR request: {e}")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"OCR service configuration error: {e}"
)
# Catch initialization errors from get_gemini_client()
logger.error(f"Gemini client runtime error during OCR request: {e}")
raise OcrServiceUnavailableError(f"OCR service configuration error: {e}")
except Exception as e:
# Catch any other unexpected errors
logger.exception(f"Unexpected error during OCR extraction for user {current_user.email}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred during item extraction.",
)
raise OcrServiceUnavailableError("An unexpected error occurred during item extraction.")
finally:
# Ensure file handle is closed (UploadFile uses SpooledTemporaryFile)
# Ensure file handle is closed
await image_file.close()

View File

@ -3,6 +3,7 @@ import os
from pydantic_settings import BaseSettings
from dotenv import load_dotenv
import logging
import secrets
load_dotenv()
logger = logging.getLogger(__name__)
@ -12,10 +13,9 @@ class Settings(BaseSettings):
GEMINI_API_KEY: str | None = None
# --- JWT Settings ---
# Generate a strong secret key using: openssl rand -hex 32
SECRET_KEY: str = "a_very_insecure_default_secret_key_replace_me" # !! MUST BE CHANGED IN PRODUCTION !!
SECRET_KEY: str # Must be set via environment variable
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
class Config:
env_file = ".env"
@ -26,23 +26,18 @@ settings = Settings()
# Validation for critical settings
if settings.DATABASE_URL is None:
print("Warning: DATABASE_URL environment variable not set.")
# raise ValueError("DATABASE_URL environment variable not set.")
raise ValueError("DATABASE_URL environment variable must be set.")
# CRITICAL: Check if the default secret key is being used
if settings.SECRET_KEY == "a_very_insecure_default_secret_key_replace_me":
print("*" * 80)
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")
print("*" * 80)
# Consider raising an error in a production environment check
# if os.getenv("ENVIRONMENT") == "production":
# raise ValueError("Default SECRET_KEY is not allowed in production!")
# Enforce secure secret key
if not settings.SECRET_KEY:
raise ValueError("SECRET_KEY environment variable must be set. Generate a secure key using: openssl rand -hex 32")
# Validate secret key strength
if len(settings.SECRET_KEY) < 32:
raise ValueError("SECRET_KEY must be at least 32 characters long for security")
if settings.GEMINI_API_KEY is None:
print.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.")
logger.error("CRITICAL: GEMINI_API_KEY environment variable not set. Gemini features will be unavailable.")
else:
# Optional: Log partial key for confirmation (avoid logging full key)
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
"""
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.
Args:
image_bytes: The image content as bytes.
mime_type: The MIME type of the image (e.g., "image/jpeg", "image/png", "image/webp").
Returns:
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
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
}

View File

@ -2,122 +2,154 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # For eager loading members
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List
from app.models import User as UserModel, Group as GroupModel, UserGroup as UserGroupModel
from app.schemas.group import GroupCreate
from app.models import UserRoleEnum # Import enum
# --- Keep existing functions: get_user_by_email, create_user ---
# (These are actually user CRUD, should ideally be in user.py, but keep for now if working)
from app.core.security import hash_password
from app.schemas.user import UserCreate # Assuming create_user uses this
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]:
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 ---
from app.core.exceptions import (
GroupOperationError,
GroupNotFoundError,
DatabaseConnectionError,
DatabaseIntegrityError,
DatabaseQueryError,
DatabaseTransactionError
)
# --- Group CRUD ---
async def create_group(db: AsyncSession, group_in: GroupCreate, creator_id: int) -> GroupModel:
"""Creates a group and adds the creator as the owner."""
db_group = GroupModel(name=group_in.name, created_by_id=creator_id)
db.add(db_group)
await db.flush() # Flush to get the db_group.id for the UserGroup entry
try:
async with db.begin():
db_group = GroupModel(name=group_in.name, created_by_id=creator_id)
db.add(db_group)
await db.flush()
# Add creator as owner
db_user_group = UserGroupModel(
user_id=creator_id,
group_id=db_group.id,
role=UserRoleEnum.owner # Use the Enum member
)
db.add(db_user_group)
await db.commit()
await db.refresh(db_group)
return db_group
db_user_group = UserGroupModel(
user_id=creator_id,
group_id=db_group.id,
role=UserRoleEnum.owner
)
db.add(db_user_group)
await db.flush()
await db.refresh(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]:
"""Gets all groups a user is a member of."""
result = await db.execute(
select(GroupModel)
.join(UserGroupModel)
.where(UserGroupModel.user_id == user_id)
.options(selectinload(GroupModel.member_associations)) # Optional: preload associations if needed often
)
return result.scalars().all()
try:
async with db.begin():
result = await db.execute(
select(GroupModel)
.join(UserGroupModel)
.where(UserGroupModel.user_id == user_id)
.options(selectinload(GroupModel.member_associations))
)
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]:
"""Gets a single group by its ID, optionally loading members."""
# Use selectinload to eager load members and their user details
result = await db.execute(
select(GroupModel)
.where(GroupModel.id == group_id)
.options(
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user)
)
)
return result.scalars().first()
try:
async with db.begin():
result = await db.execute(
select(GroupModel)
.where(GroupModel.id == group_id)
.options(
selectinload(GroupModel.member_associations).selectinload(UserGroupModel.user)
)
)
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:
"""Checks if a user is a member of a specific group."""
result = await db.execute(
select(UserGroupModel.id) # Select just one column for existence check
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
.limit(1)
)
return result.scalar_one_or_none() is not None
try:
async with db.begin():
result = await db.execute(
select(UserGroupModel.id)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
.limit(1)
)
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]:
"""Gets the role of a user in a specific group."""
result = await db.execute(
select(UserGroupModel.role)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
)
role = result.scalar_one_or_none()
return role # Will be None if not a member, or the UserRoleEnum value
"""Gets the role of a user in a specific group."""
try:
async with db.begin():
result = await db.execute(
select(UserGroupModel.role)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
)
return result.scalar_one_or_none()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query user role: {str(e)}")
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."""
# Check if already exists
existing = await db.execute(
select(UserGroupModel).where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
)
if existing.scalar_one_or_none():
return None # Indicate user already in group
try:
async with db.begin():
existing = await db.execute(
select(UserGroupModel).where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
)
if existing.scalar_one_or_none():
return None
db_user_group = UserGroupModel(user_id=user_id, group_id=group_id, role=role)
db.add(db_user_group)
await db.commit()
await db.refresh(db_user_group)
return db_user_group
db_user_group = UserGroupModel(user_id=user_id, group_id=group_id, role=role)
db.add(db_user_group)
await db.flush()
await db.refresh(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:
"""Removes a user from a group."""
result = await db.execute(
delete(UserGroupModel)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
.returning(UserGroupModel.id) # Optional: check if a row was actually deleted
)
await db.commit()
return result.scalar_one_or_none() is not None # True if deletion happened
try:
async with db.begin():
result = await db.execute(
delete(UserGroupModel)
.where(UserGroupModel.group_id == group_id, UserGroupModel.user_id == user_id)
.returning(UserGroupModel.id)
)
return result.scalar_one_or_none() is not None
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:
"""Counts the number of members in a group."""
result = await db.execute(
select(func.count(UserGroupModel.id)).where(UserGroupModel.group_id == group_id)
)
return result.scalar_one()
try:
async with db.begin():
result = await db.execute(
select(func.count(UserGroupModel.id)).where(UserGroupModel.group_id == group_id)
)
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,66 +2,107 @@
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 sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
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
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:
"""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
try:
async with db.begin():
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.flush()
await db.refresh(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]:
"""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()
try:
async with db.begin():
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()
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]:
"""Gets a single item by its ID."""
result = await db.execute(select(ItemModel).where(ItemModel.id == item_id))
return result.scalars().first()
try:
async with db.begin():
result = await db.execute(select(ItemModel).where(ItemModel.id == item_id))
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:
"""Updates an existing item record."""
update_data = item_in.model_dump(exclude_unset=True) # Get only provided fields
try:
async with db.begin():
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)
# 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)
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
db.add(item_db) # Add to session to track changes
await db.flush()
await db.refresh(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:
"""Deletes an item record."""
await db.delete(item_db)
await db.commit()
return None # Or return True/False
try:
async with db.begin():
await db.delete(item_db)
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,150 +2,175 @@
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 sqlalchemy import or_, and_, delete as sql_delete, func as sql_func, desc
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List as PyList
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.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:
"""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
try:
async with db.begin():
db_list = ListModel(
name=list_in.name,
description=list_in.description,
group_id=list_in.group_id,
created_by_id=creator_id,
is_complete=False
)
db.add(db_list)
await db.flush()
await db.refresh(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]:
"""
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()
"""Gets all lists accessible by a user."""
try:
async with db.begin():
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
query = select(ListModel).where(
or_(
and_(ListModel.created_by_id == user_id, ListModel.group_id == None),
ListModel.group_id.in_(user_group_ids)
)
).order_by(ListModel.updated_at.desc())
result = await db.execute(query)
return result.scalars().all()
result = await db.execute(query)
return result.scalars().all()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query user lists: {str(e)}")
async def get_list_by_id(db: AsyncSession, list_id: int, load_items: bool = False) -> Optional[ListModel]:
"""Gets a single list by ID, optionally loading its items."""
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()
try:
async with db.begin():
query = select(ListModel).where(ListModel.id == list_id)
if load_items:
query = query.options(
selectinload(ListModel.items)
.options(
joinedload(ItemModel.added_by_user),
joinedload(ItemModel.completed_by_user)
)
)
result = await db.execute(query)
return result.scalars().first()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to query list: {str(e)}")
async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate) -> ListModel:
"""Updates an existing list record."""
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
try:
async with db.begin():
update_data = list_in.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(list_db, key, value)
db.add(list_db)
await db.flush()
await db.refresh(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:
"""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
try:
async with db.begin():
await db.delete(list_db)
return None
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) -> Optional[ListModel]:
"""
Fetches a list and verifies user permission.
async def check_list_permission(db: AsyncSession, list_id: int, user_id: int, require_creator: bool = False) -> ListModel:
"""Fetches a list and verifies user permission."""
try:
async with db.begin():
list_db = await get_list_by_id(db, list_id=list_id, load_items=True)
if not list_db:
raise ListNotFoundError(list_id)
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.
is_creator = list_db.created_by_id == user_id
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
if require_creator:
if not is_creator:
raise ListCreatorRequiredError(list_id, "access")
return list_db
# Check if user is the creator
is_creator = list_db.created_by_id == user_id
if is_creator:
return list_db
if require_creator:
return list_db if is_creator else None
if list_db.group_id:
from app.crud.group import is_user_member
is_member = await is_user_member(db, group_id=list_db.group_id, user_id=user_id)
if not is_member:
raise ListPermissionError(list_id)
return list_db
else:
raise ListPermissionError(list_id)
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")
except SQLAlchemyError as e:
raise DatabaseQueryError(f"Failed to check list permissions: {str(e)}")
# If not requiring creator, check membership if it's a group list
if is_creator:
return list_db # Creator always has access
async def get_list_status(db: AsyncSession, list_id: int) -> ListStatus:
"""Gets the update timestamps and item count for a list."""
try:
async with db.begin():
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_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
if list_updated_at is None:
raise ListNotFoundError(list_id)
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()
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()
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
)
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
)
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
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional
from app.models import User as UserModel # Alias to avoid name clash
from app.schemas.user import UserCreate
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]:
"""Fetches a user from the database by email."""
result = await db.execute(select(UserModel).filter(UserModel.email == email))
return result.scalars().first()
try:
async with db.begin():
result = await db.execute(select(UserModel).filter(UserModel.email == email))
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:
"""Creates a new user record in the database."""
_hashed_password = hash_password(user_in.password) # Keep local var name if you like
# Create SQLAlchemy model instance - explicitly map fields
db_user = UserModel(
email=user_in.email,
# Use the correct keyword argument matching the model column name
password_hash=_hashed_password,
name=user_in.name
)
db.add(db_user)
await db.commit()
await db.refresh(db_user) # Refresh to get DB-generated values like ID, created_at
return db_user
try:
async with db.begin():
_hashed_password = hash_password(user_in.password)
db_user = UserModel(
email=user_in.email,
password_hash=_hashed_password,
name=user_in.name
)
db.add(db_user)
await db.flush() # Flush to get DB-generated values
await db.refresh(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)}")