refactor: Clean up code and improve API structure

This commit is contained in:
mohamad 2025-06-09 15:14:34 +02:00
parent 10845d2e5f
commit 8a0457aeec
63 changed files with 579 additions and 2666 deletions

View File

@ -1,12 +1,5 @@
# app/api/api_router.py
from fastapi import APIRouter
from app.api.v1.api import api_router_v1 # Import the v1 router
from app.api.v1.api import api_router_v1
api_router = APIRouter()
# Include versioned routers here, adding the /api prefix
api_router.include_router(api_router_v1, prefix="/v1") # Mounts v1 endpoints under /api/v1/...
# Add other API versions later
# e.g., api_router.include_router(api_router_v2, prefix="/v2")
api_router.include_router(api_router_v1, prefix="/v1")

View File

@ -19,30 +19,26 @@ async def google_callback(request: Request, db: AsyncSession = Depends(get_trans
token_data = await oauth.google.authorize_access_token(request)
user_info = await oauth.google.parse_id_token(request, token_data)
# Check if user exists
existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none()
user_to_login = existing_user
if not existing_user:
# Create new user
new_user = User(
email=user_info['email'],
name=user_info.get('name', user_info.get('email')),
is_verified=True, # Email is verified by Google
is_verified=True,
is_active=True
)
db.add(new_user)
await db.flush() # Use flush instead of commit since we're in a transaction
await db.flush()
user_to_login = new_user
# Generate JWT tokens using the new backend
access_strategy = get_jwt_strategy()
refresh_strategy = get_refresh_jwt_strategy()
access_token = await access_strategy.write_token(user_to_login)
refresh_token = await refresh_strategy.write_token(user_to_login)
# Redirect to frontend with tokens
redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}"
return RedirectResponse(url=redirect_url)
@ -62,12 +58,10 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa
if 'email' not in user_info:
return RedirectResponse(url=f"{settings.FRONTEND_URL}/auth/callback?error=apple_email_missing")
# Check if user exists
existing_user = (await db.execute(select(User).where(User.email == user_info['email']))).scalar_one_or_none()
user_to_login = existing_user
if not existing_user:
# Create new user
name_info = user_info.get('name', {})
first_name = name_info.get('firstName', '')
last_name = name_info.get('lastName', '')
@ -76,21 +70,19 @@ async def apple_callback(request: Request, db: AsyncSession = Depends(get_transa
new_user = User(
email=user_info['email'],
name=full_name,
is_verified=True, # Email is verified by Apple
is_verified=True,
is_active=True
)
db.add(new_user)
await db.flush() # Use flush instead of commit since we're in a transaction
await db.flush()
user_to_login = new_user
# Generate JWT tokens using the new backend
access_strategy = get_jwt_strategy()
refresh_strategy = get_refresh_jwt_strategy()
access_token = await access_strategy.write_token(user_to_login)
refresh_token = await refresh_strategy.write_token(user_to_login)
# Redirect to frontend with tokens
redirect_url = f"{settings.FRONTEND_URL}/auth/callback?access_token={access_token}&refresh_token={refresh_token}"
return RedirectResponse(url=redirect_url)
@ -113,7 +105,6 @@ async def refresh_jwt_token(request: Request):
access_strategy = get_jwt_strategy()
access_token = await access_strategy.write_token(user)
# Optionally, issue a new refresh token (rotation)
new_refresh_token = await refresh_strategy.write_token(user)
return JSONResponse({
"access_token": access_token,

View File

@ -23,5 +23,3 @@ api_router_v1.include_router(costs.router, prefix="/costs", tags=["Costs"])
api_router_v1.include_router(financials.router, prefix="/financials", tags=["Financials"])
api_router_v1.include_router(chores.router, prefix="/chores", tags=["Chores"])
api_router_v1.include_router(oauth.router, prefix="/auth", tags=["Auth"])
# Add other v1 endpoint routers here later
# e.g., api_router_v1.include_router(users.router, prefix="/users", tags=["Users"])

View File

@ -20,7 +20,6 @@ from app.core.exceptions import ChoreNotFoundError, PermissionDeniedError, Group
logger = logging.getLogger(__name__)
router = APIRouter()
# Add this new endpoint before the personal chores section
@router.get(
"/all",
response_model=PyList[ChorePublic],
@ -28,13 +27,12 @@ router = APIRouter()
tags=["Chores"]
)
async def list_all_chores(
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all chores (personal and group) for the current user in a single optimized request."""
logger.info(f"User {current_user.email} listing all their chores")
# Use the optimized function that reduces database queries
all_chores = await crud_chore.get_all_user_chores(db=db, user_id=current_user.id)
return all_chores
@ -135,14 +133,12 @@ async def delete_personal_chore(
"""Deletes a personal chore for the current user."""
logger.info(f"User {current_user.email} deleting personal chore ID: {chore_id}")
try:
# First, verify it's a personal chore belonging to the user
chore_to_delete = await crud_chore.get_chore_by_id(db, chore_id)
if not chore_to_delete or chore_to_delete.type != ChoreTypeEnum.personal or chore_to_delete.created_by_id != current_user.id:
raise ChoreNotFoundError(chore_id=chore_id, detail="Personal chore not found or not owned by user.")
success = await crud_chore.delete_chore(db=db, chore_id=chore_id, user_id=current_user.id, group_id=None)
if not success:
# This case should be rare if the above check passes and DB is consistent
raise ChoreNotFoundError(chore_id=chore_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except ChoreNotFoundError as e:
@ -156,7 +152,6 @@ async def delete_personal_chore(
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=e.detail)
# --- Group Chores Endpoints ---
# (These would be similar to what you might have had before, but now explicitly part of this router)
@router.post(
"/groups/{group_id}/chores",
@ -235,7 +230,6 @@ async def update_group_chore(
if chore_in.group_id is not None and chore_in.group_id != group_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Chore's group_id if provided must match path group_id ({group_id}).")
# Ensure chore_in has the correct type for the CRUD operation
chore_payload = chore_in.model_copy(update={"type": ChoreTypeEnum.group, "group_id": group_id} if chore_in.type is None else {"group_id": group_id})
try:
@ -271,15 +265,12 @@ async def delete_group_chore(
"""Deletes a chore from a group, ensuring user has permission."""
logger.info(f"User {current_user.email} deleting chore ID {chore_id} from group {group_id}")
try:
# Verify chore exists and belongs to the group before attempting deletion via CRUD
# This gives a more precise error if the chore exists but isn't in this group.
chore_to_delete = await crud_chore.get_chore_by_id_and_group(db, chore_id, group_id, current_user.id) # checks permission too
if not chore_to_delete : # get_chore_by_id_and_group will raise PermissionDeniedError if user not member
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
success = await crud_chore.delete_chore(db=db, chore_id=chore_id, user_id=current_user.id, group_id=group_id)
if not success:
# This case should be rare if the above check passes and DB is consistent
raise ChoreNotFoundError(chore_id=chore_id, group_id=group_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
except ChoreNotFoundError as e:
@ -331,7 +322,7 @@ async def create_chore_assignment(
)
async def list_my_assignments(
include_completed: bool = False,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all chore assignments for the current user."""
@ -350,7 +341,7 @@ async def list_my_assignments(
)
async def list_chore_assignments(
chore_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all assignments for a specific chore."""
@ -471,7 +462,6 @@ async def get_chore_history(
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves the history of a specific chore."""
# First, check if user has permission to view the chore itself
chore = await crud_chore.get_chore_by_id(db, chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=chore_id)
@ -503,10 +493,9 @@ async def get_chore_assignment_history(
if not assignment:
raise ChoreNotFoundError(assignment_id=assignment_id)
# Check permission by checking permission on the parent chore
chore = await crud_chore.get_chore_by_id(db, assignment.chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=assignment.chore_id) # Should not happen if assignment exists
raise ChoreNotFoundError(chore_id=assignment.chore_id)
if chore.type == ChoreTypeEnum.personal and chore.created_by_id != current_user.id:
raise PermissionDeniedError("You can only view history for assignments of your own personal chores.")

View File

@ -1,4 +1,4 @@
# app/api/v1/endpoints/costs.py
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
@ -18,14 +18,14 @@ from app.models import (
UserGroup as UserGroupModel,
SplitTypeEnum,
ExpenseSplit as ExpenseSplitModel,
Settlement as SettlementModel,
SettlementActivity as SettlementActivityModel # Added
SettlementActivity as SettlementActivityModel,
Settlement as SettlementModel
)
from app.schemas.cost import ListCostSummary, GroupBalanceSummary, UserCostShare, UserBalanceDetail, SuggestedSettlement
from app.schemas.expense import ExpenseCreate
from app.crud import list as crud_list
from app.crud import expense as crud_expense
from app.core.exceptions import ListNotFoundError, ListPermissionError, UserNotFoundError, GroupNotFoundError
from app.core.exceptions import ListNotFoundError, ListPermissionError, GroupNotFoundError
logger = logging.getLogger(__name__)
router = APIRouter()

View File

@ -1,4 +1,3 @@
# app/api/v1/endpoints/groups.py
import logging
from typing import List
@ -7,11 +6,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session, get_session
from app.auth import current_active_user
from app.models import User as UserModel, UserRoleEnum # Import model and enum
from app.models import User as UserModel, UserRoleEnum
from app.schemas.group import GroupCreate, GroupPublic, GroupScheduleGenerateRequest
from app.schemas.invite import InviteCodePublic
from app.schemas.message import Message # For simple responses
from app.schemas.list import ListPublic, ListDetail
from app.schemas.message import Message
from app.schemas.list import ListDetail
from app.schemas.chore import ChoreHistoryPublic, ChoreAssignmentPublic
from app.schemas.user import UserPublic
from app.crud import group as crud_group
@ -46,8 +45,6 @@ async def create_group(
"""Creates a new group, adding the creator as the owner."""
logger.info(f"User {current_user.email} creating group: {group_in.name}")
created_group = await crud_group.create_group(db=db, group_in=group_in, creator_id=current_user.id)
# Load members explicitly if needed for the response (optional here)
# created_group = await crud_group.get_group_by_id(db, created_group.id)
return created_group
@ -58,7 +55,7 @@ async def create_group(
tags=["Groups"]
)
async def read_user_groups(
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all groups the current user is a member of."""
@ -75,12 +72,11 @@ async def read_user_groups(
)
async def read_group(
group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves details for a specific group, including members, if the user is part of it."""
logger.info(f"User {current_user.email} requesting details for group ID: {group_id}")
# Check if user is a member first
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}")
@ -101,13 +97,12 @@ async def read_group(
)
async def read_group_members(
group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all members of a specific group, if the user is part of it."""
logger.info(f"User {current_user.email} requesting members for group ID: {group_id}")
# Check if user is a member first
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}")
@ -118,7 +113,6 @@ async def read_group_members(
logger.error(f"Group {group_id} requested by member {current_user.email} not found (data inconsistency?)")
raise GroupNotFoundError(group_id)
# Extract and return just the user information from member associations
return [member_assoc.user for member_assoc in group.member_associations]
@router.post(
@ -136,12 +130,10 @@ async def create_group_invite(
logger.info(f"User {current_user.email} attempting to create invite for group {group_id}")
user_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
# --- 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 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 GroupNotFoundError(group_id)
@ -149,7 +141,6 @@ async def create_group_invite(
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}")
# This case should ideally be covered by exceptions from create_invite now
raise InviteCreationError(group_id)
logger.info(f"User {current_user.email} created invite code for group {group_id}")
@ -163,26 +154,20 @@ async def create_group_invite(
)
async def get_group_active_invite(
group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves the active invite code for the group. Requires group membership (owner/admin to be stricter later if needed)."""
logger.info(f"User {current_user.email} attempting to get active invite for group {group_id}")
# Permission check: Ensure user is a member of the group to view invite code
# Using get_user_role_in_group which also checks membership indirectly
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: # Not a member
logger.warning(f"Permission denied: User {current_user.email} is not a member of group {group_id} and cannot view invite code.")
# More specific error or let GroupPermissionError handle if we want to be generic
raise GroupMembershipError(group_id, "view invite code for this group (not a member)")
# Fetch the active invite for the group
invite = await crud_invite.get_active_invite_for_group(db, group_id=group_id)
if not invite:
# This case means no active (non-expired, active=true) invite exists.
# The frontend can then prompt to generate one.
logger.info(f"No active invite code found for group {group_id} when requested by {current_user.email}")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@ -190,7 +175,7 @@ async def get_group_active_invite(
)
logger.info(f"User {current_user.email} retrieved active invite code for group {group_id}")
return invite # Pydantic will convert InviteModel to InviteCodePublic
return invite
@router.delete(
"/{group_id}/leave",
@ -210,27 +195,22 @@ async def leave_group(
if user_role is None:
raise GroupMembershipError(group_id, "leave (you are not a member)")
# Check if owner is the last member
if user_role == UserRoleEnum.owner:
member_count = await crud_group.get_group_member_count(db, group_id)
if member_count <= 1:
# Delete the group since owner is the last member
logger.info(f"Owner {current_user.email} is the last member. Deleting group {group_id}")
await crud_group.delete_group(db, group_id)
return Message(detail="Group deleted as you were the last member")
# Proceed with removal for non-owner or if there are other members
deleted = await crud_group.remove_user_from_group(db, group_id=group_id, user_id=current_user.id)
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 GroupOperationError("Failed to leave group")
logger.info(f"User {current_user.email} successfully left group {group_id}")
return Message(detail="Successfully left the group")
# --- Optional: Remove Member Endpoint ---
@router.delete(
"/{group_id}/members/{user_id_to_remove}",
response_model=Message,
@ -247,21 +227,17 @@ async def remove_group_member(
logger.info(f"Owner {current_user.email} attempting to remove user {user_id_to_remove} from group {group_id}")
owner_role = await crud_group.get_user_role_in_group(db, group_id=group_id, user_id=current_user.id)
# --- 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 GroupPermissionError(group_id, "remove members")
# Prevent owner removing themselves via this endpoint
if current_user.id == user_id_to_remove:
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 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:
@ -279,19 +255,17 @@ async def remove_group_member(
)
async def read_group_lists(
group_id: int,
db: AsyncSession = Depends(get_session), # Use read-only session for GET
db: AsyncSession = Depends(get_session),
current_user: UserModel = Depends(current_active_user),
):
"""Retrieves all lists belonging to a specific group, if the user is a member."""
logger.info(f"User {current_user.email} requesting lists for group ID: {group_id}")
# Check if user is a member first
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 GroupMembershipError(group_id, "view group lists")
# Get all lists for the user and filter by group_id
lists = await crud_list.get_lists_for_user(db=db, user_id=current_user.id)
group_lists = [list for list in lists if list.group_id == group_id]
@ -311,7 +285,6 @@ async def generate_group_chore_schedule(
):
"""Generates a round-robin chore schedule for a group."""
logger.info(f"User {current_user.email} generating chore schedule for group {group_id}")
# Permission check: ensure user is a member (or owner/admin if stricter rules are needed)
if not await crud_group.is_user_member(db, group_id, current_user.id):
raise GroupMembershipError(group_id, "generate chore schedule for this group")
@ -342,7 +315,6 @@ async def get_group_chore_history(
):
"""Retrieves all chore-related history for a specific group."""
logger.info(f"User {current_user.email} requesting chore history for group {group_id}")
# Permission check
if not await crud_group.is_user_member(db, group_id, current_user.id):
raise GroupMembershipError(group_id, "view chore history for this group")

View File

@ -1,4 +1,3 @@
# app/api/v1/endpoints/health.py
import logging
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
@ -7,7 +6,6 @@ from sqlalchemy.sql import text
from app.database import get_transactional_session
from app.schemas.health import HealthStatus
from app.core.exceptions import DatabaseConnectionError
logger = logging.getLogger(__name__)
router = APIRouter()
@ -22,17 +20,9 @@ async def check_health(db: AsyncSession = Depends(get_transactional_session)):
"""
Health check endpoint. Verifies API reachability and database connection.
"""
try:
# Try executing a simple query to check DB connection
result = await db.execute(text("SELECT 1"))
if result.scalar_one() == 1:
logger.info("Health check successful: Database connection verified.")
return HealthStatus(status="ok", database="connected")
else:
# This case should ideally not happen with 'SELECT 1'
logger.error("Health check failed: Database connection check returned 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)
raise DatabaseConnectionError(str(e))
result = await db.execute(text("SELECT 1"))
if result.scalar_one() == 1:
logger.info("Health check successful: Database connection verified.")
return HealthStatus(status="ok", database="connected")
logger.error("Health check failed: Database connection check returned unexpected result.")
raise DatabaseConnectionError("Unexpected result from database connection check")

View File

@ -1,21 +1,16 @@
# app/api/v1/endpoints/invites.py
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session
from app.auth import current_active_user
from app.models import User as UserModel, UserRoleEnum
from app.models import User as UserModel
from app.schemas.invite import InviteAccept
from app.schemas.message import Message
from app.schemas.group import GroupPublic
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,
GroupOperationError
@ -25,7 +20,7 @@ logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"/accept", # Route relative to prefix "/invites"
"/accept",
response_model=GroupPublic,
summary="Accept Group Invite",
tags=["Invites"]
@ -37,42 +32,33 @@ async def accept_invite(
):
"""Accepts a group invite using the provided invite code."""
logger.info(f"User {current_user.email} attempting to accept invite code: {invite_in.code}")
# Get the invite - this function should only return valid, active invites
invite = await crud_invite.get_active_invite_by_code(db, code=invite_in.code)
if not invite:
logger.warning(f"Invalid or inactive invite code attempted by user {current_user.email}: {invite_in.code}")
# We can use a more generic error or a specific one. InviteNotFound is reasonable.
raise InviteNotFoundError(invite_in.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.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.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
added_to_group = await crud_group.add_user_to_group(db, group_id=invite.group_id, user_id=current_user.id)
if not added_to_group:
logger.error(f"Failed to add user {current_user.email} to group {invite.group_id} during invite acceptance.")
# This could be a race condition or other issue, treat as an operational error.
raise GroupOperationError("Failed to add user to group.")
# Deactivate the invite so it cannot be used again
await crud_invite.deactivate_invite(db, invite=invite)
logger.info(f"User {current_user.email} successfully joined group {invite.group_id} via invite {invite_in.code}")
# Re-fetch the group to get the updated member list
updated_group = await crud_group.get_group_by_id(db, group_id=invite.group_id)
if not updated_group:
# This should ideally not happen as we found it before
logger.error(f"Could not re-fetch group {invite.group_id} after user {current_user.email} joined.")
raise GroupNotFoundError(invite.group_id)

View File

@ -1,4 +1,4 @@
# app/api/v1/endpoints/items.py
import logging
from typing import List as PyList, Optional
@ -6,21 +6,17 @@ from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session
from app.auth import current_active_user
# --- Import Models Correctly ---
from app.models import User as UserModel
from app.models import Item as ItemModel # <-- IMPORT Item and alias it
# --- End Import Models ---
from app.models import Item as ItemModel
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, ConflictError
from app.auth import current_active_user
logger = logging.getLogger(__name__)
router = APIRouter()
# --- Helper Dependency for Item Permissions ---
# Now ItemModel is defined before being used as a type hint
async def get_item_and_verify_access(
item_id: int,
db: AsyncSession = Depends(get_transactional_session),
@ -31,19 +27,15 @@ async def get_item_and_verify_access(
if not item_db:
raise ItemNotFoundError(item_id)
# Check permission on the parent list
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 ---
@router.post(
"/lists/{list_id}/items", # Nested under lists
"/lists/{list_id}/items",
response_model=ItemPublic,
status_code=status.HTTP_201_CREATED,
summary="Add Item to List",
@ -56,13 +48,11 @@ async def create_list_item(
current_user: UserModel = Depends(current_active_user),
):
"""Adds a new item to a specific list. User must have access to the list."""
user_email = current_user.email # Access email attribute before async operations
user_email = current_user.email
logger.info(f"User {user_email} adding item to list {list_id}: {item_in.name}")
# Verify user has access to the target list
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(
@ -73,7 +63,7 @@ async def create_list_item(
@router.get(
"/lists/{list_id}/items", # Nested under lists
"/lists/{list_id}/items",
response_model=PyList[ItemPublic],
summary="List Items in List",
tags=["Items"]
@ -82,16 +72,13 @@ async def read_list_items(
list_id: int,
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
# Add sorting/filtering params later if needed: sort_by: str = 'created_at', order: str = 'asc'
):
"""Retrieves all items for a specific list if the user has access."""
user_email = current_user.email # Access email attribute before async operations
user_email = current_user.email
logger.info(f"User {user_email} listing items for list {list_id}")
# Verify user has access to the list
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
except ListPermissionError as e:
raise ListPermissionError(list_id, "view items in this list")
items = await crud_item.get_items_by_list_id(db=db, list_id=list_id)
@ -99,7 +86,7 @@ async def read_list_items(
@router.put(
"/lists/{list_id}/items/{item_id}", # Nested under lists
"/lists/{list_id}/items/{item_id}",
response_model=ItemPublic,
summary="Update Item",
tags=["Items"],
@ -111,9 +98,9 @@ async def update_item(
list_id: int,
item_id: int,
item_in: ItemUpdate,
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
item_db: ItemModel = Depends(get_item_and_verify_access),
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user), # Need user ID for completed_by
current_user: UserModel = Depends(current_active_user),
):
"""
Updates an item's details (name, quantity, is_complete, price).
@ -122,9 +109,8 @@ async def update_item(
If the version does not match, a 409 Conflict is returned.
Sets/unsets `completed_by_id` based on `is_complete` flag.
"""
user_email = current_user.email # Access email attribute before async operations
user_email = current_user.email
logger.info(f"User {user_email} attempting to update item ID: {item_id} with version {item_in.version}")
# Permission check is handled by get_item_and_verify_access dependency
try:
updated_item = await crud_item.update_item(
@ -141,7 +127,7 @@ async def update_item(
@router.delete(
"/lists/{list_id}/items/{item_id}", # Nested under lists
"/lists/{list_id}/items/{item_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete Item",
tags=["Items"],
@ -153,18 +139,16 @@ async def delete_item(
list_id: int,
item_id: int,
expected_version: Optional[int] = Query(None, description="The expected version of the item to delete for optimistic locking."),
item_db: ItemModel = Depends(get_item_and_verify_access), # Use dependency to get item and check list access
item_db: ItemModel = Depends(get_item_and_verify_access),
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user), # Log who deleted it
current_user: UserModel = Depends(current_active_user),
):
"""
Deletes an item. User must have access to the list the item belongs to.
If `expected_version` is provided and does not match the item's current version,
a 409 Conflict is returned.
"""
user_email = current_user.email # Access email attribute before async operations
logger.info(f"User {user_email} attempting to delete item ID: {item_id}, expected version: {expected_version}")
# Permission check is handled by get_item_and_verify_access dependency
user_email = current_user.email
if expected_version is not None and item_db.version != expected_version:
logger.warning(

View File

@ -1,34 +1,27 @@
# app/api/v1/endpoints/lists.py
import logging
from typing import List as PyList, Optional # Alias for Python List type hint
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query # Added Query
from typing import List as PyList, Optional
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_transactional_session
from app.auth import current_active_user
from app.models import User as UserModel
from app.schemas.list import ListCreate, ListUpdate, ListPublic, ListDetail
from app.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.crud import group as crud_group
from app.schemas.list import ListStatus, ListStatusWithId
from app.schemas.expense import ExpensePublic # Import ExpensePublic
from app.schemas.expense import ExpensePublic
from app.core.exceptions import (
GroupMembershipError,
ListNotFoundError,
ListPermissionError,
ListStatusNotFoundError,
ConflictError, # Added ConflictError
DatabaseIntegrityError # Added DatabaseIntegrityError
ConflictError,
DatabaseIntegrityError
)
logger = logging.getLogger(__name__)
router = APIRouter()
@router.post(
"", # Route relative to prefix "/lists"
response_model=ListPublic, # Return basic list info on creation
"",
response_model=ListPublic,
status_code=status.HTTP_201_CREATED,
summary="Create New List",
tags=["Lists"],
@ -53,7 +46,6 @@ async def create_list(
logger.info(f"User {current_user.email} creating list: {list_in.name}")
group_id = list_in.group_id
# Permission Check: If sharing with a group, verify membership
if group_id:
is_member = await crud_group.is_user_member(db, group_id=group_id, user_id=current_user.id)
if not is_member:
@ -65,9 +57,7 @@ async def create_list(
logger.info(f"List '{created_list.name}' (ID: {created_list.id}) created successfully for user {current_user.email}.")
return created_list
except DatabaseIntegrityError as e:
# Check if this is a unique constraint violation
if "unique constraint" in str(e).lower():
# Find the existing list with the same name in the group
existing_list = await crud_list.get_list_by_name_and_group(
db=db,
name=list_in.name,
@ -81,20 +71,18 @@ async def create_list(
detail=f"A list named '{list_in.name}' already exists in this group.",
headers={"X-Existing-List": str(existing_list.id)}
)
# If it's not a unique constraint or we couldn't find the existing list, re-raise
raise
@router.get(
"", # Route relative to prefix "/lists"
response_model=PyList[ListDetail], # Return a list of detailed list info including items
"",
response_model=PyList[ListDetail],
summary="List Accessible Lists",
tags=["Lists"]
)
async def read_lists(
db: AsyncSession = Depends(get_transactional_session),
current_user: UserModel = Depends(current_active_user),
# Add pagination parameters later if needed: skip: int = 0, limit: int = 100
):
"""
Retrieves lists accessible to the current user:
@ -128,7 +116,6 @@ async def read_lists_statuses(
statuses = await crud_list.get_lists_statuses_by_ids(db=db, list_ids=ids, user_id=current_user.id)
# The CRUD function returns a list of Row objects, so we map them to the Pydantic model
return [
ListStatusWithId(
id=s.id,
@ -141,7 +128,7 @@ async def read_lists_statuses(
@router.get(
"/{list_id}",
response_model=ListDetail, # Return detailed list info including items
response_model=ListDetail,
summary="Get List Details",
tags=["Lists"]
)
@ -155,17 +142,16 @@ 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}")
# 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)
return list_db
@router.put(
"/{list_id}",
response_model=ListPublic, # Return updated basic info
response_model=ListPublic,
summary="Update List",
tags=["Lists"],
responses={ # Add 409 to responses
responses={
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified by someone else"}
}
)
@ -188,22 +174,20 @@ async def update_list(
updated_list = await crud_list.update_list(db=db, list_db=list_db, list_in=list_in)
logger.info(f"List {list_id} updated successfully by user {current_user.email} to version {updated_list.version}.")
return updated_list
except ConflictError as e: # Catch and re-raise as HTTPException for proper FastAPI response
except ConflictError as e:
logger.warning(f"Conflict updating list {list_id} for user {current_user.email}: {str(e)}")
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
except Exception as e: # Catch other potential errors from crud operation
except Exception as e:
logger.error(f"Error updating list {list_id} for user {current_user.email}: {str(e)}")
# Consider a more generic error, but for now, let's keep it specific if possible
# Re-raising might be better if crud layer already raises appropriate HTTPExceptions
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating the list.")
@router.delete(
"/{list_id}",
status_code=status.HTTP_204_NO_CONTENT, # Standard for successful DELETE with no body
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete List",
tags=["Lists"],
responses={ # Add 409 to responses
responses={
status.HTTP_409_CONFLICT: {"description": "Conflict: List has been modified, cannot delete specified version"}
}
)
@ -219,7 +203,6 @@ async def delete_list(
a 409 Conflict is returned.
"""
logger.info(f"User {current_user.email} attempting to delete list ID: {list_id}, expected version: {expected_version}")
# 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 expected_version is not None and list_db.version != expected_version:
@ -253,7 +236,6 @@ async def read_list_status(
if the user has permission (creator or group member).
"""
logger.info(f"User {current_user.email} requesting status for list ID: {list_id}")
# The check_list_permission is not needed here as get_list_status handles not found
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
return await crud_list.get_list_status(db=db, list_id=list_id)
@ -278,9 +260,7 @@ async def read_list_expenses(
logger.info(f"User {current_user.email} requesting expenses for list ID: {list_id}")
# Check if user has permission to access this list
await crud_list.check_list_permission(db=db, list_id=list_id, user_id=current_user.id)
# Get expenses for this list
expenses = await crud_expense.get_expenses_for_list(db, list_id=list_id, skip=skip, limit=limit)
return expenses

View File

@ -1,17 +1,12 @@
import logging
from typing import List
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status
from google.api_core import exceptions as google_exceptions
from fastapi import APIRouter, Depends, UploadFile, File
from app.auth import current_active_user
from app.models import User as UserModel
from app.schemas.ocr import OcrExtractResponse
from app.core.gemini import GeminiOCRService, gemini_initialization_error
from app.core.exceptions import (
from app.core.exceptions import (
OCRServiceUnavailableError,
OCRServiceConfigError,
OCRUnexpectedError,
OCRQuotaExceededError,
InvalidFileTypeError,
FileTooLargeError,
@ -37,26 +32,22 @@ async def ocr_extract_items(
Accepts an image upload, sends it to Gemini Flash with a prompt
to extract shopping list items, and returns the parsed items.
"""
# Check if Gemini client initialized correctly
if 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 settings.ALLOWED_IMAGE_TYPES:
logger.warning(f"Invalid file type uploaded by {current_user.email}: {image_file.content_type}")
raise InvalidFileTypeError()
# Simple size check
contents = await image_file.read()
if len(contents) > settings.MAX_FILE_SIZE_MB * 1024 * 1024:
logger.warning(f"File too large uploaded by {current_user.email}: {len(contents)} bytes")
raise FileTooLargeError()
try:
# Use the ocr_service instance instead of the standalone function
extracted_items = await ocr_service.extract_items(image_data=contents)
logger.info(f"Successfully extracted {len(extracted_items)} items for user {current_user.email}.")
@ -72,5 +63,4 @@ async def ocr_extract_items(
raise OCRProcessingError(str(e))
finally:
# Ensure file handle is closed
await image_file.close()

View File

@ -21,11 +21,9 @@ from .database import get_session
from .models import User
from .config import settings
# OAuth2 configuration
config = Config('.env')
oauth = OAuth(config)
# Google OAuth2 setup
oauth.register(
name='google',
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
@ -35,7 +33,6 @@ oauth.register(
}
)
# Apple OAuth2 setup
oauth.register(
name='apple',
server_metadata_url='https://appleid.apple.com/.well-known/openid-configuration',
@ -45,13 +42,11 @@ oauth.register(
}
)
# Custom Bearer Response with Refresh Token
class BearerResponseWithRefresh(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
# Custom Bearer Transport that supports refresh tokens
class BearerTransportWithRefresh(BearerTransport):
async def get_login_response(self, token: str, refresh_token: str = None) -> Response:
if refresh_token:
@ -61,14 +56,12 @@ class BearerTransportWithRefresh(BearerTransport):
token_type="bearer"
)
else:
# Fallback to standard response if no refresh token
bearer_response = {
"access_token": token,
"token_type": "bearer"
}
return JSONResponse(bearer_response.dict() if hasattr(bearer_response, 'dict') else bearer_response)
# Custom Authentication Backend with Refresh Token Support
class AuthenticationBackendWithRefresh(AuthenticationBackend):
def __init__(
self,
@ -83,7 +76,6 @@ class AuthenticationBackendWithRefresh(AuthenticationBackend):
self.get_refresh_strategy = get_refresh_strategy
async def login(self, strategy, user) -> Response:
# Generate both access and refresh tokens
access_token = await strategy.write_token(user)
refresh_strategy = self.get_refresh_strategy()
refresh_token = await refresh_strategy.write_token(user)
@ -124,17 +116,14 @@ async def get_user_db(session: AsyncSession = Depends(get_session)):
async def get_user_manager(user_db: SQLAlchemyUserDatabase = Depends(get_user_db)):
yield UserManager(user_db)
# Updated transport with refresh token support
bearer_transport = BearerTransportWithRefresh(tokenUrl="auth/jwt/login")
def get_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60)
def get_refresh_jwt_strategy() -> JWTStrategy:
# Refresh tokens last longer - 7 days
def get_refresh_jwt_strategy() -> JWTStrategy:
return JWTStrategy(secret=settings.SECRET_KEY, lifetime_seconds=7 * 24 * 60 * 60)
# Updated auth backend with refresh token support
auth_backend = AuthenticationBackendWithRefresh(
name="jwt",
transport=bearer_transport,

View File

@ -1,28 +1,20 @@
from typing import Dict, Any
from app.config import settings
# API Version
API_VERSION = "v1"
# API Prefix
API_PREFIX = f"/api/{API_VERSION}"
# API Endpoints
class APIEndpoints:
# Auth
AUTH = {
"LOGIN": "/auth/login",
"SIGNUP": "/auth/signup",
"REFRESH_TOKEN": "/auth/refresh-token",
}
# Users
USERS = {
"PROFILE": "/users/profile",
"UPDATE_PROFILE": "/users/profile",
}
# Lists
LISTS = {
"BASE": "/lists",
"BY_ID": "/lists/{id}",
@ -30,7 +22,6 @@ class APIEndpoints:
"ITEM": "/lists/{list_id}/items/{item_id}",
}
# Groups
GROUPS = {
"BASE": "/groups",
"BY_ID": "/groups/{id}",
@ -38,7 +29,6 @@ class APIEndpoints:
"MEMBERS": "/groups/{group_id}/members",
}
# Invites
INVITES = {
"BASE": "/invites",
"BY_ID": "/invites/{id}",
@ -46,12 +36,10 @@ class APIEndpoints:
"DECLINE": "/invites/{id}/decline",
}
# OCR
OCR = {
"PROCESS": "/ocr/process",
}
# Financials
FINANCIALS = {
"EXPENSES": "/financials/expenses",
"EXPENSE": "/financials/expenses/{id}",
@ -59,12 +47,10 @@ class APIEndpoints:
"SETTLEMENT": "/financials/settlements/{id}",
}
# Health
HEALTH = {
"CHECK": "/health",
}
# API Metadata
API_METADATA = {
"title": settings.API_TITLE,
"description": settings.API_DESCRIPTION,
@ -74,7 +60,6 @@ API_METADATA = {
"redoc_url": settings.API_REDOC_URL,
}
# API Tags
API_TAGS = [
{"name": "Authentication", "description": "Authentication and authorization endpoints"},
{"name": "Users", "description": "User management endpoints"},
@ -86,7 +71,7 @@ API_TAGS = [
{"name": "Health", "description": "Health check endpoints"},
]
# Helper function to get full API URL
def get_api_url(endpoint: str, **kwargs) -> str:
"""
Get the full API URL for an endpoint.

View File

@ -48,7 +48,6 @@ def calculate_next_due_date(
today = date.today()
reference_future_date = max(today, base_date)
# This loop ensures the next_due date is always in the future relative to the reference_future_date.
while next_due <= reference_future_date:
current_base_for_recalc = next_due
@ -70,9 +69,7 @@ def calculate_next_due_date(
else: # Should not be reached
break
# Safety break: if date hasn't changed, interval is zero or logic error.
if next_due == current_base_for_recalc:
# Log error ideally, then advance by one day to prevent infinite loop.
next_due += timedelta(days=1)
break

View File

@ -362,4 +362,3 @@ class PermissionDeniedError(HTTPException):
detail=detail
)
# Financials & Cost Splitting specific errors

View File

@ -1,8 +1,6 @@
# app/core/gemini.py
import logging
from typing import List
import google.generativeai as genai
from google.generativeai.types import HarmCategory, HarmBlockThreshold # For safety settings
from google.api_core import exceptions as google_exceptions
from app.config import settings
from app.core.exceptions import (
@ -15,15 +13,12 @@ from app.core.exceptions import (
logger = logging.getLogger(__name__)
# --- Global variable to hold the initialized model client ---
gemini_flash_client = None
gemini_initialization_error = None # Store potential init error
gemini_initialization_error = None
# --- Configure and Initialize ---
try:
if settings.GEMINI_API_KEY:
genai.configure(api_key=settings.GEMINI_API_KEY)
# Initialize the specific model we want to use
gemini_flash_client = genai.GenerativeModel(
model_name=settings.GEMINI_MODEL_NAME,
generation_config=genai.types.GenerationConfig(
@ -32,18 +27,15 @@ try:
)
logger.info(f"Gemini AI client initialized successfully for model '{settings.GEMINI_MODEL_NAME}'.")
else:
# Store error if API key is missing
gemini_initialization_error = "GEMINI_API_KEY not configured. Gemini client not initialized."
logger.error(gemini_initialization_error)
except Exception as e:
# Catch any other unexpected errors during initialization
gemini_initialization_error = f"Failed to initialize Gemini AI client: {e}"
logger.exception(gemini_initialization_error) # Log full traceback
gemini_flash_client = None # Ensure client is None on error
logger.exception(gemini_initialization_error)
gemini_flash_client = None
# --- Function to get the client (optional, allows checking error) ---
def get_gemini_client():
"""
Returns the initialized Gemini client instance.
@ -52,23 +44,172 @@ def get_gemini_client():
if gemini_initialization_error:
raise OCRServiceConfigError()
if gemini_flash_client is None:
# This case should ideally be covered by the check above, but as a safeguard:
raise OCRServiceConfigError()
return gemini_flash_client
# Define the prompt as a constant
OCR_ITEM_EXTRACTION_PROMPT = """
Extract the shopping list items from this image.
List each distinct item on a new line.
Ignore prices, quantities, store names, discounts, taxes, totals, and other non-item text.
Focus only on the names of the products or items to be purchased.
If the image does not appear to be a shopping list or receipt, state that clearly.
Example output for a grocery list:
Milk
Eggs
Bread
Apples
Organic Bananas
**ROLE & GOAL**
You are an expert AI assistant specializing in Optical Character Recognition (OCR) and structured data extraction. Your primary function is to act as a "Shopping List Digitizer."
Your goal is to meticulously analyze the provided image of a shopping list, which is likely handwritten, and convert it into a structured, machine-readable JSON format. You must be accurate, infer context where necessary, and handle the inherent ambiguities of handwriting and informal list-making.
**INPUT**
You will receive a single image (`[Image]`). This image contains a shopping list. It may be:
* Neatly written or very messy.
* On lined paper, a whiteboard, a napkin, or a dedicated notepad.
* Containing doodles, stains, or other visual noise.
* Using various formats (bullet points, numbered lists, columns, simple line breaks).
* could be in English or in German.
**CORE TASK: STEP-BY-STEP ANALYSIS**
Follow these steps precisely:
1. **Initial Image Analysis & OCR:**
* Perform an advanced OCR scan on the entire image to transcribe all visible text.
* Pay close attention to the spatial layout. Identify headings, columns, and line items. Note which text elements appear to be grouped together.
2. **Item Identification & Filtering:**
* Differentiate between actual list items and non-item elements.
* **INCLUDE:** Items intended for purchase.
* **EXCLUDE:** List titles (e.g., "GROCERIES," "Target List"), dates, doodles, unrelated notes, or stray marks. Capture the list title separately if one exists.
3. **Detailed Extraction for Each Item:**
For every single item you identify, extract the following attributes. If an attribute is not present, use `null`.
* `item_name` (string): The primary name of the product.
* **Standardize:** Normalize the name. (e.g., "B. Powder" -> "Baking Powder", "A. Juice" -> "Apple Juice").
* **Contextual Guessing:** If a word is poorly written, use the context of a shopping list to make an educated guess. (e.g., "Ciffee" is almost certainly "Coffee").
* `quantity` (number or string): The amount needed.
* If a number is present (e.g., "**2** milks"), extract the number `2`.
* If it's a word (e.g., "**a dozen** eggs"), extract the string `"a dozen"`.
* If no quantity is specified (e.g., "Bread"), infer a default quantity of `1`.
* `unit` (string): The unit of measurement or packaging.
* Examples: "kg", "lbs", "liters", "gallons", "box", "can", "bag", "bunch".
* Infer where possible (e.g., for "2 Milks," the unit could be inferred as "cartons" or "gallons" depending on regional context, but it's safer to leave it `null` if not explicitly stated).
* `notes` (string): Any additional descriptive text.
* Examples: "low-sodium," "organic," "brand name (Tide)," "for the cake," "get the ripe ones."
* `category` (string): Infer a logical category for the item.
* Use common grocery store categories: `Produce`, `Dairy & Eggs`, `Meat & Seafood`, `Pantry`, `Frozen`, `Bakery`, `Beverages`, `Household`, `Personal Care`.
* If the list itself has category headings (e.g., a "DAIRY" section), use those first.
* `original_text` (string): Provide the exact, unaltered text that your OCR transcribed for this entire line item. This is crucial for verification.
* `is_crossed_out` (boolean): Set to `true` if the item is struck through, crossed out, or clearly marked as completed. Otherwise, set to `false`.
**HANDLING AMBIGUITIES AND EDGE CASES**
* **Illegible Text:** If a line or word is completely unreadable, set `item_name` to `"UNREADABLE"` and place the garbled OCR attempt in the `original_text` field.
* **Abbreviations:** Expand common shopping list abbreviations (e.g., "OJ" -> "Orange Juice", "TP" -> "Toilet Paper", "AVOs" -> "Avocados", "G. Beef" -> "Ground Beef").
* **Implicit Items:** If a line is vague like "Snacks for kids," list it as is. Do not invent specific items.
* **Multi-item Lines:** If a line contains multiple items (e.g., "Onions, Garlic, Ginger"), split them into separate item objects.
**OUTPUT FORMAT**
Your final output MUST be a single JSON object with the following structure. Do not include any explanatory text before or after the JSON block.
```json
{
"list_title": "string or null",
"items": [
{
"item_name": "string",
"quantity": "number or string",
"unit": "string or null",
"category": "string",
"notes": "string or null",
"original_text": "string",
"is_crossed_out": "boolean"
}
],
"summary": {
"total_items": "integer",
"unread_items": "integer",
"crossed_out_items": "integer"
}
}
```
**EXAMPLE WALKTHROUGH**
* **IF THE IMAGE SHOWS:** A crumpled sticky note with the title "Stuff for tonight" and the items:
* `2x Chicken Breasts`
* `~~Baguette~~` (this item is crossed out)
* `Salad mix (bag)`
* `Tomatos` (misspelled)
* `Choc Ice Cream`
* **YOUR JSON OUTPUT SHOULD BE:**
```json
{
"list_title": "Stuff for tonight",
"items": [
{
"item_name": "Chicken Breasts",
"quantity": 2,
"unit": null,
"category": "Meat & Seafood",
"notes": null,
"original_text": "2x Chicken Breasts",
"is_crossed_out": false
},
{
"item_name": "Baguette",
"quantity": 1,
"unit": null,
"category": "Bakery",
"notes": null,
"original_text": "Baguette",
"is_crossed_out": true
},
{
"item_name": "Salad Mix",
"quantity": 1,
"unit": "bag",
"category": "Produce",
"notes": null,
"original_text": "Salad mix (bag)",
"is_crossed_out": false
},
{
"item_name": "Tomatoes",
"quantity": 1,
"unit": null,
"category": "Produce",
"notes": null,
"original_text": "Tomatos",
"is_crossed_out": false
},
{
"item_name": "Chocolate Ice Cream",
"quantity": 1,
"unit": null,
"category": "Frozen",
"notes": null,
"original_text": "Choc Ice Cream",
"is_crossed_out": false
}
],
"summary": {
"total_items": 5,
"unread_items": 0,
"crossed_out_items": 1
}
}
```
**FINAL INSTRUCTION**
If the image provided is not a shopping list or is completely blank/unintelligible, respond with a JSON object where the `items` array is empty and add a note in the `list_title` field, such as "Image does not appear to be a shopping list."
Now, analyze the provided image and generate the JSON output.
"""
async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "image/jpeg") -> List[str]:
@ -92,29 +233,22 @@ async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "
try:
client = get_gemini_client() # Raises OCRServiceConfigError if not initialized
# Prepare image part for multimodal input
image_part = {
"mime_type": mime_type,
"data": image_bytes
}
# Prepare the full prompt content
prompt_parts = [
settings.OCR_ITEM_EXTRACTION_PROMPT, # Text prompt first
image_part # Then the image
settings.OCR_ITEM_EXTRACTION_PROMPT,
image_part
]
logger.info("Sending image to Gemini for item extraction...")
# Make the API call
# Use generate_content_async for async FastAPI
response = await client.generate_content_async(prompt_parts)
# --- Process the response ---
# Check for safety blocks or lack of content
if not response.candidates or not response.candidates[0].content.parts:
logger.warning("Gemini response blocked or empty.", extra={"response": response})
# Check finish_reason if available
finish_reason = response.candidates[0].finish_reason if response.candidates else 'UNKNOWN'
safety_ratings = response.candidates[0].safety_ratings if response.candidates else 'N/A'
if finish_reason == 'SAFETY':
@ -122,18 +256,13 @@ async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "
else:
raise OCRUnexpectedError()
# Extract text - assumes the first part of the first candidate is the text response
raw_text = response.text # response.text is a shortcut for response.candidates[0].content.parts[0].text
raw_text = response.text
logger.info("Received raw text from Gemini.")
# logger.debug(f"Gemini Raw Text:\n{raw_text}") # Optional: Log full response text
# Parse the text response
items = []
for line in raw_text.splitlines(): # Split by newline
cleaned_line = line.strip() # Remove leading/trailing whitespace
# Basic filtering: ignore empty lines and potential non-item lines
if cleaned_line and len(cleaned_line) > 1: # Ignore very short lines too?
# Add more sophisticated filtering if needed (e.g., regex, keyword check)
for line in raw_text.splitlines():
cleaned_line = line.strip()
if cleaned_line and len(cleaned_line) > 1:
items.append(cleaned_line)
logger.info(f"Extracted {len(items)} potential items.")
@ -145,12 +274,9 @@ async def extract_items_from_image_gemini(image_bytes: bytes, mime_type: str = "
raise OCRQuotaExceededError()
raise OCRServiceUnavailableError()
except (OCRServiceConfigError, OCRQuotaExceededError, OCRServiceUnavailableError, OCRProcessingError, OCRUnexpectedError):
# Re-raise specific OCR exceptions
raise
except Exception as e:
# Catch other unexpected errors during generation or processing
logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True)
# Wrap in a custom exception
raise OCRUnexpectedError()
class GeminiOCRService:
@ -186,27 +312,22 @@ class GeminiOCRService:
OCRUnexpectedError: For any other unexpected errors.
"""
try:
# Create image part
image_parts = [{"mime_type": mime_type, "data": image_data}]
# Generate content
response = await self.model.generate_content_async(
contents=[settings.OCR_ITEM_EXTRACTION_PROMPT, *image_parts]
)
# Process response
if not response.text:
logger.warning("Gemini response is empty")
raise OCRUnexpectedError()
# Check for safety blocks
if hasattr(response, 'candidates') and response.candidates and hasattr(response.candidates[0], 'finish_reason'):
finish_reason = response.candidates[0].finish_reason
if finish_reason == 'SAFETY':
safety_ratings = response.candidates[0].safety_ratings if hasattr(response.candidates[0], 'safety_ratings') else 'N/A'
raise OCRProcessingError(f"Gemini response blocked due to safety settings. Ratings: {safety_ratings}")
# Split response into lines and clean up
items = []
for line in response.text.splitlines():
cleaned_line = line.strip()
@ -222,7 +343,6 @@ class GeminiOCRService:
raise OCRQuotaExceededError()
raise OCRServiceUnavailableError()
except (OCRServiceConfigError, OCRQuotaExceededError, OCRServiceUnavailableError, OCRProcessingError, OCRUnexpectedError):
# Re-raise specific OCR exceptions
raise
except Exception as e:
logger.error(f"Unexpected error during Gemini item extraction: {e}", exc_info=True)

View File

@ -2,7 +2,6 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore
from apscheduler.executors.pool import ThreadPoolExecutor
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from app.config import settings
from app.jobs.recurring_expenses import generate_recurring_expenses
from app.db.session import async_session
@ -10,11 +9,8 @@ import logging
logger = logging.getLogger(__name__)
# Convert async database URL to sync URL for APScheduler
# Replace postgresql+asyncpg:// with postgresql://
sync_db_url = settings.DATABASE_URL.replace('postgresql+asyncpg://', 'postgresql://')
# Configure the scheduler
jobstores = {
'default': SQLAlchemyJobStore(url=sync_db_url)
}
@ -36,7 +32,10 @@ scheduler = AsyncIOScheduler(
)
async def run_recurring_expenses_job():
"""Wrapper function to run the recurring expenses job with a database session."""
"""Wrapper function to run the recurring expenses job with a database session.
This function is used to generate recurring expenses for the user.
"""
try:
async with async_session() as session:
await generate_recurring_expenses(session)
@ -47,7 +46,6 @@ async def run_recurring_expenses_job():
def init_scheduler():
"""Initialize and start the scheduler."""
try:
# Add the recurring expenses job
scheduler.add_job(
run_recurring_expenses_job,
trigger=CronTrigger(hour=0, minute=0), # Run at midnight UTC
@ -56,7 +54,6 @@ def init_scheduler():
replace_existing=True
)
# Start the scheduler
scheduler.start()
logger.info("Scheduler started successfully")
except Exception as e:

View File

@ -1,20 +1,5 @@
# app/core/security.py
from datetime import datetime, timedelta, timezone
from typing import Any, Union, Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings # Import settings from config
# --- Password Hashing ---
# These functions are used for password hashing and verification
# They complement FastAPI-Users but provide direct access to the underlying password functionality
# when needed outside of the FastAPI-Users authentication flow.
# Configure passlib context
# Using bcrypt as the default hashing scheme
# 'deprecated="auto"' will automatically upgrade hashes if needed on verification
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
@ -33,7 +18,6 @@ def verify_password(plain_password: str, hashed_password: str) -> bool:
try:
return pwd_context.verify(plain_password, hashed_password)
except Exception:
# Handle potential errors during verification (e.g., invalid hash format)
return False
def hash_password(password: str) -> str:
@ -48,26 +32,4 @@ def hash_password(password: str) -> str:
Returns:
The resulting hash string.
"""
return pwd_context.hash(password)
# --- JSON Web Tokens (JWT) ---
# FastAPI-Users now handles all JWT token creation and validation.
# The code below is commented out because FastAPI-Users provides these features.
# It's kept for reference in case a custom implementation is needed later.
# Example of a potential future implementation:
# def get_subject_from_token(token: str) -> Optional[str]:
# """
# Extract the subject (user ID) from a JWT token.
# This would be used if we need to validate tokens outside of FastAPI-Users flow.
# For now, use fastapi_users.current_user dependency instead.
# """
# # This would need to use FastAPI-Users' token verification if ever implemented
# # For example, by decoding the token using the strategy from the auth backend
# try:
# payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
# return payload.get("sub")
# except JWTError:
# return None
# return None
return pwd_context.hash(password)

View File

@ -18,16 +18,14 @@ logger = logging.getLogger(__name__)
async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
"""Gets all chores (personal and group) for a user in optimized queries."""
# Get personal chores query
personal_chores_query = (
select(Chore)
select(Chore)
.where(
Chore.created_by_id == user_id,
Chore.type == ChoreTypeEnum.personal
)
)
# Get user's group IDs first
user_groups_result = await db.execute(
select(UserGroup.group_id).where(UserGroup.user_id == user_id)
)
@ -35,7 +33,6 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
all_chores = []
# Execute personal chores query
personal_result = await db.execute(
personal_chores_query
.options(
@ -48,7 +45,6 @@ async def get_all_user_chores(db: AsyncSession, user_id: int) -> List[Chore]:
)
all_chores.extend(personal_result.scalars().all())
# If user has groups, get all group chores in one query
if user_group_ids:
group_chores_result = await db.execute(
select(Chore)
@ -76,12 +72,10 @@ async def create_chore(
group_id: Optional[int] = None
) -> Chore:
"""Creates a new chore, either personal or within a specific group."""
# Use the transaction pattern from the FastAPI strategy
async with db.begin_nested() if db.in_transaction() else db.begin():
if chore_in.type == ChoreTypeEnum.group:
if not group_id:
raise ValueError("group_id is required for group chores")
# Validate group existence and user membership
group = await get_group_by_id(db, group_id)
if not group:
raise GroupNotFoundError(group_id)
@ -97,14 +91,12 @@ async def create_chore(
created_by_id=user_id,
)
# Specific check for custom frequency
if chore_in.frequency == ChoreFrequencyEnum.custom and chore_in.custom_interval_days is None:
raise ValueError("custom_interval_days must be set for custom frequency chores.")
db.add(db_chore)
await db.flush() # Get the ID for the chore
await db.flush()
# Log history
await create_chore_history_entry(
db,
chore_id=db_chore.id,
@ -115,7 +107,6 @@ async def create_chore(
)
try:
# Load relationships for the response with eager loading
result = await db.execute(
select(Chore)
.where(Chore.id == db_chore.id)
@ -221,10 +212,8 @@ async def update_chore(
if not db_chore:
raise ChoreNotFoundError(chore_id, group_id)
# Store original state for history
original_data = {field: getattr(db_chore, field) for field in chore_in.model_dump(exclude_unset=True)}
# Check permissions
if db_chore.type == ChoreTypeEnum.group:
if not group_id:
raise ValueError("group_id is required for group chores")
@ -232,7 +221,7 @@ async def update_chore(
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {group_id}")
if db_chore.group_id != group_id:
raise ChoreNotFoundError(chore_id, group_id)
else: # personal chore
else:
if group_id:
raise ValueError("group_id must be None for personal chores")
if db_chore.created_by_id != user_id:
@ -240,7 +229,6 @@ async def update_chore(
update_data = chore_in.model_dump(exclude_unset=True)
# Handle type change
if 'type' in update_data:
new_type = update_data['type']
if new_type == ChoreTypeEnum.group and not group_id:
@ -275,7 +263,6 @@ async def update_chore(
if db_chore.frequency == ChoreFrequencyEnum.custom and db_chore.custom_interval_days is None:
raise ValueError("custom_interval_days must be set for custom frequency chores.")
# Log history for changes
changes = {}
for field, old_value in original_data.items():
new_value = getattr(db_chore, field)
@ -293,7 +280,7 @@ async def update_chore(
)
try:
await db.flush() # Flush changes within the transaction
await db.flush()
result = await db.execute(
select(Chore)
.where(Chore.id == db_chore.id)
@ -322,7 +309,6 @@ async def delete_chore(
if not db_chore:
raise ChoreNotFoundError(chore_id, group_id)
# Log history before deleting
await create_chore_history_entry(
db,
chore_id=chore_id,
@ -332,7 +318,6 @@ async def delete_chore(
event_data={"chore_name": db_chore.name}
)
# Check permissions
if db_chore.type == ChoreTypeEnum.group:
if not group_id:
raise ValueError("group_id is required for group chores")
@ -348,7 +333,7 @@ async def delete_chore(
try:
await db.delete(db_chore)
await db.flush() # Ensure deletion is processed within the transaction
await db.flush()
return True
except Exception as e:
logger.error(f"Error deleting chore {chore_id}: {e}", exc_info=True)
@ -363,27 +348,23 @@ async def create_chore_assignment(
) -> ChoreAssignment:
"""Creates a new chore assignment. User must be able to manage the chore."""
async with db.begin_nested() if db.in_transaction() else db.begin():
# Get the chore and validate permissions
chore = await get_chore_by_id(db, assignment_in.chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=assignment_in.chore_id)
# Check permissions to assign this chore
if chore.type == ChoreTypeEnum.personal:
if chore.created_by_id != user_id:
raise PermissionDeniedError(detail="Only the creator can assign personal chores")
else: # group chore
if not await is_user_member(db, chore.group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}")
# For group chores, check if assignee is also a group member
if not await is_user_member(db, chore.group_id, assignment_in.assigned_to_user_id):
raise PermissionDeniedError(detail=f"Cannot assign chore to user {assignment_in.assigned_to_user_id} who is not a group member")
db_assignment = ChoreAssignment(**assignment_in.model_dump(exclude_unset=True))
db.add(db_assignment)
await db.flush() # Get the ID for the assignment
await db.flush()
# Log history
await create_assignment_history_entry(
db,
assignment_id=db_assignment.id,
@ -393,7 +374,6 @@ async def create_chore_assignment(
)
try:
# Load relationships for the response
result = await db.execute(
select(ChoreAssignment)
.where(ChoreAssignment.id == db_assignment.id)
@ -450,12 +430,11 @@ async def get_chore_assignments(
chore = await get_chore_by_id(db, chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=chore_id)
# Check permissions
if chore.type == ChoreTypeEnum.personal:
if chore.created_by_id != user_id:
raise PermissionDeniedError(detail="Can only view assignments for own personal chores")
else: # group chore
else:
if not await is_user_member(db, chore.group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}")
@ -487,11 +466,10 @@ async def update_chore_assignment(
if not chore:
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
# Check permissions - only assignee can complete, but chore managers can reschedule
can_manage = False
if chore.type == ChoreTypeEnum.personal:
can_manage = chore.created_by_id == user_id
else: # group chore
else:
can_manage = await is_user_member(db, chore.group_id, user_id)
can_complete = db_assignment.assigned_to_user_id == user_id
@ -501,7 +479,6 @@ async def update_chore_assignment(
original_assignee = db_assignment.assigned_to_user_id
original_due_date = db_assignment.due_date
# Check specific permissions for different updates
if 'is_complete' in update_data and not can_complete:
raise PermissionDeniedError(detail="Only the assignee can mark assignments as complete")
@ -515,7 +492,6 @@ async def update_chore_assignment(
raise PermissionDeniedError(detail="Only chore managers can reassign assignments")
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REASSIGNED, event_data={"old": original_assignee, "new": update_data['assigned_to_user_id']})
# Handle completion logic
if 'is_complete' in update_data:
if update_data['is_complete'] and not db_assignment.is_complete:
update_data['completed_at'] = datetime.utcnow()
@ -531,13 +507,11 @@ async def update_chore_assignment(
update_data['completed_at'] = None
await create_assignment_history_entry(db, assignment_id=assignment_id, changed_by_user_id=user_id, event_type=ChoreHistoryEventTypeEnum.REOPENED)
# Apply updates
for field, value in update_data.items():
setattr(db_assignment, field, value)
try:
await db.flush()
# Load relationships for the response
result = await db.execute(
select(ChoreAssignment)
.where(ChoreAssignment.id == db_assignment.id)
@ -563,7 +537,6 @@ async def delete_chore_assignment(
if not db_assignment:
raise ChoreNotFoundError(assignment_id=assignment_id)
# Log history before deleting
await create_assignment_history_entry(
db,
assignment_id=assignment_id,
@ -572,22 +545,20 @@ async def delete_chore_assignment(
event_data={"unassigned_user_id": db_assignment.assigned_to_user_id}
)
# Load the chore for permission checking
chore = await get_chore_by_id(db, db_assignment.chore_id)
if not chore:
raise ChoreNotFoundError(chore_id=db_assignment.chore_id)
# Check permissions
if chore.type == ChoreTypeEnum.personal:
if chore.created_by_id != user_id:
raise PermissionDeniedError(detail="Only the creator can delete personal chore assignments")
else: # group chore
else:
if not await is_user_member(db, chore.group_id, user_id):
raise PermissionDeniedError(detail=f"User {user_id} not a member of group {chore.group_id}")
try:
await db.delete(db_assignment)
await db.flush() # Ensure deletion is processed within the transaction
await db.flush()
return True
except Exception as e:
logger.error(f"Error deleting chore assignment {assignment_id}: {e}", exc_info=True)

View File

@ -1,15 +1,14 @@
# app/crud/group.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # For eager loading members
from sqlalchemy.orm import selectinload
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List
from sqlalchemy import delete, func
import logging # Add logging import
import logging
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
from app.models import UserRoleEnum
from app.core.exceptions import (
GroupOperationError,
GroupNotFoundError,

View File

@ -1,4 +1,3 @@
# be/app/crud/history.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload
@ -76,7 +75,7 @@ async def get_group_chore_history(db: AsyncSession, group_id: int) -> List[Chore
.where(ChoreHistory.group_id == group_id)
.options(
selectinload(ChoreHistory.changed_by_user),
selectinload(ChoreHistory.chore) # Also load chore info if available
selectinload(ChoreHistory.chore)
)
.order_by(ChoreHistory.timestamp.desc())
)

View File

@ -1,26 +1,24 @@
# app/crud/invite.py
import logging # Add logging import
import logging
import secrets
from datetime import datetime, timedelta, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # Ensure selectinload is imported
from sqlalchemy import delete # Import delete statement
from sqlalchemy.orm import selectinload
from sqlalchemy import delete
from sqlalchemy.exc import SQLAlchemyError, OperationalError, IntegrityError
from typing import Optional
from app.models import Invite as InviteModel, Group as GroupModel, User as UserModel # Import related models for selectinload
from app.models import Invite as InviteModel, Group as GroupModel, User as UserModel
from app.core.exceptions import (
DatabaseConnectionError,
DatabaseIntegrityError,
DatabaseQueryError,
DatabaseTransactionError,
InviteOperationError # Add new specific exception
InviteOperationError
)
logger = logging.getLogger(__name__) # Initialize logger
logger = logging.getLogger(__name__)
# Invite codes should be reasonably unique, but handle potential collision
MAX_CODE_GENERATION_ATTEMPTS = 5
async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: int):
@ -35,15 +33,13 @@ async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: in
active_invites = result.scalars().all()
if not active_invites:
return # No active invites to deactivate
return
for invite in active_invites:
invite.is_active = False
db.add(invite)
await db.flush() # Flush changes within this transaction block
await db.flush()
# await db.flush() # Removed: Rely on caller to flush/commit
# No explicit commit here, assuming it's part of a larger transaction or caller handles commit.
except OperationalError as e:
logger.error(f"Database connection error deactivating invites for group {group_id}: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"DB connection error deactivating invites for group {group_id}: {str(e)}")
@ -51,12 +47,11 @@ async def deactivate_all_active_invites_for_group(db: AsyncSession, group_id: in
logger.error(f"Unexpected SQLAlchemy error deactivating invites for group {group_id}: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"DB transaction error deactivating invites for group {group_id}: {str(e)}")
async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 365 * 100) -> Optional[InviteModel]: # Default to 100 years
async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expires_in_days: int = 365 * 100) -> Optional[InviteModel]:
"""Creates a new invite code for a group, deactivating any existing active ones for that group first."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin():
# Deactivate existing active invites for this group
await deactivate_all_active_invites_for_group(db, group_id)
expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
@ -101,7 +96,7 @@ async def create_invite(db: AsyncSession, group_id: int, creator_id: int, expire
raise InviteOperationError("Failed to load invite after creation and flush.")
return loaded_invite
except InviteOperationError: # Already specific, re-raise
except InviteOperationError:
raise
except IntegrityError as e:
logger.error(f"Database integrity error during invite creation for group {group_id}: {str(e)}", exc_info=True)
@ -121,13 +116,12 @@ async def get_active_invite_for_group(db: AsyncSession, group_id: int) -> Option
select(InviteModel).where(
InviteModel.group_id == group_id,
InviteModel.is_active == True,
InviteModel.expires_at > now # Still respect expiry, even if very long
InviteModel.expires_at > now
)
.order_by(InviteModel.created_at.desc()) # Get the most recent one if multiple (should not happen)
.limit(1)
.options(
selectinload(InviteModel.group), # Eager load group
selectinload(InviteModel.creator) # Eager load creator
selectinload(InviteModel.group),
selectinload(InviteModel.creator)
)
)
result = await db.execute(stmt)
@ -166,10 +160,9 @@ async def deactivate_invite(db: AsyncSession, invite: InviteModel) -> InviteMode
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
invite.is_active = False
db.add(invite) # Add to session to track change
await db.flush() # Persist is_active change
db.add(invite)
await db.flush()
# Re-fetch with relationships
stmt = (
select(InviteModel)
.where(InviteModel.id == invite.id)
@ -181,7 +174,7 @@ async def deactivate_invite(db: AsyncSession, invite: InviteModel) -> InviteMode
result = await db.execute(stmt)
updated_invite = result.scalar_one_or_none()
if updated_invite is None: # Should not happen as invite is passed in
if updated_invite is None:
raise InviteOperationError("Failed to load invite after deactivation.")
return updated_invite
@ -192,8 +185,3 @@ async def deactivate_invite(db: AsyncSession, invite: InviteModel) -> InviteMode
logger.error(f"Unexpected SQLAlchemy error deactivating invite: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"DB transaction error deactivating invite: {str(e)}")
# Ensure InviteOperationError is defined in app.core.exceptions
# Example: class InviteOperationError(AppException): pass
# Optional: Function to periodically delete old, inactive invites
# async def cleanup_old_invites(db: AsyncSession, older_than_days: int = 30): ...

View File

@ -1,15 +1,14 @@
# app/crud/item.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # Ensure selectinload is imported
from sqlalchemy import delete as sql_delete, update as sql_update # Use aliases
from sqlalchemy.orm import selectinload
from sqlalchemy import delete as sql_delete, update as sql_update
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List as PyList
from datetime import datetime, timezone
import logging # Add logging import
import logging
from sqlalchemy import func
from app.models import Item as ItemModel, User as UserModel # Import UserModel for type hints if needed for selectinload
from app.models import Item as ItemModel, User as UserModel
from app.schemas.item import ItemCreate, ItemUpdate
from app.core.exceptions import (
ItemNotFoundError,
@ -18,16 +17,15 @@ from app.core.exceptions import (
DatabaseQueryError,
DatabaseTransactionError,
ConflictError,
ItemOperationError # Add if specific item operation errors are needed
ItemOperationError
)
logger = logging.getLogger(__name__) # Initialize logger
logger = logging.getLogger(__name__)
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, setting its position."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
# Get the current max position in the list
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Start transaction
max_pos_stmt = select(func.max(ItemModel.position)).where(ItemModel.list_id == list_id)
max_pos_result = await db.execute(max_pos_stmt)
max_pos = max_pos_result.scalar_one_or_none() or 0
@ -38,26 +36,24 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
list_id=list_id,
added_by_id=user_id,
is_complete=False,
position=max_pos + 1 # Set the new position
position=max_pos + 1
)
db.add(db_item)
await db.flush() # Assigns ID
await db.flush()
# Re-fetch with relationships
stmt = (
select(ItemModel)
.where(ItemModel.id == db_item.id)
.options(
selectinload(ItemModel.added_by_user),
selectinload(ItemModel.completed_by_user) # Will be None but loads relationship
selectinload(ItemModel.completed_by_user)
)
)
result = await db.execute(stmt)
loaded_item = result.scalar_one_or_none()
if loaded_item is None:
# await transaction.rollback() # Redundant, context manager handles rollback on exception
raise ItemOperationError("Failed to load item after creation.") # Define ItemOperationError
raise ItemOperationError("Failed to load item after creation.")
return loaded_item
except IntegrityError as e:
@ -69,8 +65,6 @@ async def create_item(db: AsyncSession, item_in: ItemCreate, list_id: int, user_
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during item creation: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to create item: {str(e)}")
# Removed generic Exception block as SQLAlchemyError should cover DB issues,
# and context manager handles rollback.
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."""
@ -100,7 +94,7 @@ async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
.options(
selectinload(ItemModel.added_by_user),
selectinload(ItemModel.completed_by_user),
selectinload(ItemModel.list) # Often useful to get the parent list
selectinload(ItemModel.list)
)
)
result = await db.execute(stmt)
@ -113,7 +107,7 @@ async def get_item_by_id(db: AsyncSession, item_id: int) -> Optional[ItemModel]:
async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate, user_id: int) -> ItemModel:
"""Updates an existing item record, checking for version conflicts and handling reordering."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Start transaction
if item_db.version != item_in.version:
raise ConflictError(
f"Item '{item_db.name}' (ID: {item_db.id}) has been modified. "
@ -122,31 +116,23 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
update_data = item_in.model_dump(exclude_unset=True, exclude={'version'})
# --- Handle Reordering ---
if 'position' in update_data:
new_position = update_data.pop('position') # Remove from update_data to handle separately
new_position = update_data.pop('position')
# We need the full list to reorder, making sure it's loaded and ordered
list_id = item_db.list_id
stmt = select(ItemModel).where(ItemModel.list_id == list_id).order_by(ItemModel.position.asc(), ItemModel.created_at.asc())
result = await db.execute(stmt)
items_in_list = result.scalars().all()
# Find the item to move
item_to_move = next((it for it in items_in_list if it.id == item_db.id), None)
if item_to_move:
items_in_list.remove(item_to_move)
# Insert at the new position (adjust for 1-based index from frontend)
# Clamp position to be within bounds
insert_pos = max(0, min(new_position - 1, len(items_in_list)))
items_in_list.insert(insert_pos, item_to_move)
# Re-assign positions
for i, item in enumerate(items_in_list):
item.position = i + 1
# --- End Handle Reordering ---
if 'is_complete' in update_data:
if update_data['is_complete'] is True:
if item_db.completed_by_id is None:
@ -158,10 +144,9 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
setattr(item_db, key, value)
item_db.version += 1
db.add(item_db) # Mark as dirty
db.add(item_db)
await db.flush()
# Re-fetch with relationships
stmt = (
select(ItemModel)
.where(ItemModel.id == item_db.id)
@ -174,8 +159,7 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
result = await db.execute(stmt)
updated_item = result.scalar_one_or_none()
if updated_item is None: # Should not happen
# Rollback will be handled by context manager on raise
if updated_item is None:
raise ItemOperationError("Failed to load item after update.")
return updated_item
@ -185,7 +169,7 @@ async def update_item(db: AsyncSession, item_db: ItemModel, item_in: ItemUpdate,
except OperationalError as e:
logger.error(f"Database connection error while updating item: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while updating item: {str(e)}")
except ConflictError: # Re-raise ConflictError, rollback handled by context manager
except ConflictError:
raise
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during item update: {str(e)}", exc_info=True)
@ -196,14 +180,9 @@ async def delete_item(db: AsyncSession, item_db: ItemModel) -> None:
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
await db.delete(item_db)
# await transaction.commit() # Removed
# No return needed for None
except OperationalError as e:
logger.error(f"Database connection error while deleting item: {str(e)}", exc_info=True)
raise DatabaseConnectionError(f"Database connection error while deleting item: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error while deleting item: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to delete item: {str(e)}")
# Ensure ItemOperationError is defined in app.core.exceptions if used
# Example: class ItemOperationError(AppException): pass
raise DatabaseTransactionError(f"Failed to delete item: {str(e)}")

View File

@ -1,11 +1,10 @@
# app/crud/list.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy import or_, and_, delete as sql_delete, func as sql_func, desc
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional, List as PyList
import logging # Add logging import
import logging
from app.schemas.list import ListStatus
from app.models import List as ListModel, UserGroup as UserGroupModel, Item as ItemModel
@ -22,12 +21,12 @@ from app.core.exceptions import (
ListOperationError
)
logger = logging.getLogger(__name__) # Initialize logger
logger = logging.getLogger(__name__)
async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) -> ListModel:
"""Creates a new list record."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Start transaction
db_list = ListModel(
name=list_in.name,
description=list_in.description,
@ -36,16 +35,14 @@ async def create_list(db: AsyncSession, list_in: ListCreate, creator_id: int) ->
is_complete=False
)
db.add(db_list)
await db.flush() # Assigns ID
await db.flush()
# Re-fetch with relationships for the response
stmt = (
select(ListModel)
.where(ListModel.id == db_list.id)
.options(
selectinload(ListModel.creator),
selectinload(ListModel.group)
# selectinload(ListModel.items) # Optionally add if items are always needed in response
)
)
result = await db.execute(stmt)
@ -129,7 +126,7 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
"""Updates an existing list record, checking for version conflicts."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
if list_db.version != list_in.version: # list_db here is the one passed in, pre-loaded by API layer
if list_db.version != list_in.version:
raise ConflictError(
f"List '{list_db.name}' (ID: {list_db.id}) has been modified. "
f"Your version is {list_in.version}, current version is {list_db.version}. Please refresh."
@ -145,20 +142,18 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
db.add(list_db) # Add the already attached list_db to mark it dirty for the session
await db.flush()
# Re-fetch with relationships for the response
stmt = (
select(ListModel)
.where(ListModel.id == list_db.id)
.options(
selectinload(ListModel.creator),
selectinload(ListModel.group)
# selectinload(ListModel.items) # Optionally add if items are always needed in response
)
)
result = await db.execute(stmt)
updated_list = result.scalar_one_or_none()
if updated_list is None: # Should not happen
if updated_list is None:
raise ListOperationError("Failed to load list after update.")
return updated_list
@ -177,7 +172,7 @@ async def update_list(db: AsyncSession, list_db: ListModel, list_in: ListUpdate)
async def delete_list(db: AsyncSession, list_db: ListModel) -> None:
"""Deletes a list record. Version check should be done by the caller (API endpoint)."""
try:
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction: # Standardize transaction
async with db.begin_nested() if db.in_transaction() else db.begin() as transaction:
await db.delete(list_db)
except OperationalError as e:
logger.error(f"Database connection error while deleting list: {str(e)}", exc_info=True)
@ -257,7 +252,6 @@ async def get_list_by_name_and_group(
Used for conflict resolution when creating lists.
"""
try:
# Base query for the list itself
base_query = select(ListModel).where(ListModel.name == name)
if group_id is not None:
@ -265,7 +259,6 @@ async def get_list_by_name_and_group(
else:
base_query = base_query.where(ListModel.group_id.is_(None))
# Add eager loading for common relationships
base_query = base_query.options(
selectinload(ListModel.creator),
selectinload(ListModel.group)
@ -277,19 +270,17 @@ async def get_list_by_name_and_group(
if not target_list:
return None
# Permission check
is_creator = target_list.created_by_id == user_id
if is_creator:
return target_list
if target_list.group_id:
from app.crud.group import is_user_member # Assuming this is a quick check not needing its own transaction
from app.crud.group import is_user_member
is_member_of_group = await is_user_member(db, group_id=target_list.group_id, user_id=user_id)
if is_member_of_group:
return target_list
# If not creator and (not a group list or not a member of the group list)
return None
except OperationalError as e:
@ -305,22 +296,17 @@ async def get_lists_statuses_by_ids(db: AsyncSession, list_ids: PyList[int], use
if not list_ids:
return []
try:
# First, get the groups the user is a member of
try:
group_ids_result = await db.execute(
select(UserGroupModel.group_id).where(UserGroupModel.user_id == user_id)
)
user_group_ids = group_ids_result.scalars().all()
# Build the permission logic
permission_filter = or_(
# User is the creator of the list
and_(ListModel.created_by_id == user_id, ListModel.group_id.is_(None)),
# List belongs to a group the user is a member of
ListModel.group_id.in_(user_group_ids)
)
# Main query to get list data and item counts
query = (
select(
ListModel.id,
@ -340,11 +326,7 @@ async def get_lists_statuses_by_ids(db: AsyncSession, list_ids: PyList[int], use
result = await db.execute(query)
# The result will be rows of (id, updated_at, item_count).
# We need to verify that all requested list_ids that the user *should* have access to are present.
# The filter in the query already handles permissions.
return result.all() # Returns a list of Row objects with id, updated_at, item_count
return result.all()
except OperationalError as e:
raise DatabaseConnectionError(f"Failed to connect to database: {str(e)}")

View File

@ -1,13 +1,10 @@
# be/app/crud/schedule.py
import logging
from datetime import date, timedelta
from typing import List
from itertools import cycle
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from app.models import Chore, Group, User, ChoreAssignment, UserGroup, ChoreTypeEnum, ChoreHistoryEventTypeEnum
from app.models import Chore, ChoreAssignment, UserGroup, ChoreTypeEnum, ChoreHistoryEventTypeEnum
from app.crud.group import get_group_by_id
from app.crud.history import create_chore_history_entry
from app.core.exceptions import GroupNotFoundError, ChoreOperationError
@ -20,7 +17,7 @@ async def generate_group_chore_schedule(
group_id: int,
start_date: date,
end_date: date,
user_id: int, # The user initiating the action
user_id: int,
member_ids: List[int] = None
) -> List[ChoreAssignment]:
"""
@ -34,7 +31,6 @@ async def generate_group_chore_schedule(
raise GroupNotFoundError(group_id)
if not member_ids:
# If no members are specified, use all members from the group
members_result = await db.execute(
select(UserGroup.user_id).where(UserGroup.group_id == group_id)
)
@ -43,7 +39,6 @@ async def generate_group_chore_schedule(
if not member_ids:
raise ChoreOperationError("Cannot generate schedule with no members.")
# Fetch all chores belonging to this group
chores_result = await db.execute(
select(Chore).where(Chore.group_id == group_id, Chore.type == ChoreTypeEnum.group)
)
@ -58,16 +53,7 @@ async def generate_group_chore_schedule(
current_date = start_date
while current_date <= end_date:
for chore in group_chores:
# Check if a chore is due on the current day based on its frequency
# This is a simplified check. A more robust system would use the chore's next_due_date
# and frequency to see if it falls on the current_date.
# For this implementation, we assume we generate assignments for ALL chores on ALL days
# in the range, which might not be desired.
# A better approach is needed here. Let's assume for now we just create assignments for each chore
# on its *next* due date if it falls within the range.
if start_date <= chore.next_due_date <= end_date:
# Check if an assignment for this chore on this due date already exists
existing_assignment_result = await db.execute(
select(ChoreAssignment.id)
.where(ChoreAssignment.chore_id == chore.id, ChoreAssignment.due_date == chore.next_due_date)
@ -82,7 +68,7 @@ async def generate_group_chore_schedule(
assignment = ChoreAssignment(
chore_id=chore.id,
assigned_to_user_id=assigned_to_user_id,
due_date=chore.next_due_date, # Assign on the chore's own next_due_date
due_date=chore.next_due_date,
is_complete=False
)
db.add(assignment)
@ -95,10 +81,9 @@ async def generate_group_chore_schedule(
logger.info(f"No new assignments were generated for group {group_id} in the specified date range.")
return []
# Log a single group-level event for the schedule generation
await create_chore_history_entry(
db,
chore_id=None, # This is a group-level event
chore_id=None,
group_id=group_id,
changed_by_user_id=user_id,
event_type=ChoreHistoryEventTypeEnum.SCHEDULE_GENERATED,
@ -112,8 +97,6 @@ async def generate_group_chore_schedule(
await db.flush()
# Refresh assignments to load relationships if needed, although not strictly necessary
# as the objects are already in the session.
for assign in new_assignments:
await db.refresh(assign)

View File

@ -1,4 +1,3 @@
# app/crud/settlement.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload, joinedload
@ -7,7 +6,7 @@ from sqlalchemy.exc import SQLAlchemyError, OperationalError, IntegrityError
from decimal import Decimal, ROUND_HALF_UP
from typing import List as PyList, Optional, Sequence
from datetime import datetime, timezone
import logging # Add logging import
import logging
from app.models import (
Settlement as SettlementModel,
@ -28,7 +27,7 @@ from app.core.exceptions import (
ConflictError
)
logger = logging.getLogger(__name__) # Initialize logger
logger = logging.getLogger(__name__)
async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, current_user_id: int) -> SettlementModel:
"""Creates a new settlement record."""
@ -49,13 +48,6 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
if not group:
raise GroupNotFoundError(settlement_in.group_id)
# Permission check example (can be in API layer too)
# if current_user_id not in [payer.id, payee.id]:
# is_member_stmt = select(UserGroupModel.id).where(UserGroupModel.group_id == group.id, UserGroupModel.user_id == current_user_id).limit(1)
# is_member_result = await db.execute(is_member_stmt)
# if not is_member_result.scalar_one_or_none():
# raise InvalidOperationError("Settlement recorder must be part of the group or one of the parties.")
db_settlement = SettlementModel(
group_id=settlement_in.group_id,
paid_by_user_id=settlement_in.paid_by_user_id,
@ -68,7 +60,6 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
db.add(db_settlement)
await db.flush()
# Re-fetch with relationships
stmt = (
select(SettlementModel)
.where(SettlementModel.id == db_settlement.id)
@ -87,8 +78,6 @@ async def create_settlement(db: AsyncSession, settlement_in: SettlementCreate, c
return loaded_settlement
except (UserNotFoundError, GroupNotFoundError, InvalidOperationError) as e:
# These are validation errors, re-raise them.
# If a transaction was started, context manager handles rollback.
raise
except IntegrityError as e:
logger.error(f"Database integrity error during settlement creation: {str(e)}", exc_info=True)
@ -115,10 +104,8 @@ async def get_settlement_by_id(db: AsyncSession, settlement_id: int) -> Optional
)
return result.scalars().first()
except OperationalError as e:
# Optional: logger.warning or info if needed for read operations
raise DatabaseConnectionError(f"DB connection error fetching settlement: {str(e)}")
except SQLAlchemyError as e:
# Optional: logger.warning or info if needed for read operations
raise DatabaseQueryError(f"DB query error fetching settlement: {str(e)}")
async def get_settlements_for_group(db: AsyncSession, group_id: int, skip: int = 0, limit: int = 100) -> Sequence[SettlementModel]:
@ -183,10 +170,6 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
"""
try:
async with db.begin_nested() if db.in_transaction() else db.begin():
# Ensure the settlement_db passed is managed by the current session if not already.
# This is usually true if fetched by an endpoint dependency using the same session.
# If not, `db.add(settlement_db)` might be needed before modification if it's detached.
if not hasattr(settlement_db, 'version') or not hasattr(settlement_in, 'version'):
raise InvalidOperationError("Version field is missing in model or input for optimistic locking.")
@ -204,22 +187,14 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
if field in allowed_to_update:
setattr(settlement_db, field, value)
updated_something = True
# Silently ignore fields not allowed to update or raise error:
# else:
# raise InvalidOperationError(f"Field '{field}' cannot be updated.")
if not updated_something and not settlement_in.model_fields_set.intersection(allowed_to_update):
# No updatable fields were actually provided, or they didn't change
# Still, we might want to return the re-loaded settlement if version matched.
pass
settlement_db.version += 1
settlement_db.updated_at = datetime.now(timezone.utc) # Ensure model has this field
db.add(settlement_db) # Mark as dirty
settlement_db.updated_at = datetime.now(timezone.utc)
await db.flush()
# Re-fetch with relationships
stmt = (
select(SettlementModel)
.where(SettlementModel.id == settlement_db.id)
@ -233,11 +208,11 @@ async def update_settlement(db: AsyncSession, settlement_db: SettlementModel, se
result = await db.execute(stmt)
updated_settlement = result.scalar_one_or_none()
if updated_settlement is None: # Should not happen
if updated_settlement is None:
raise SettlementOperationError("Failed to load settlement after update.")
return updated_settlement
except ConflictError as e: # ConflictError should be defined in exceptions
except ConflictError as e:
raise
except InvalidOperationError as e:
raise
@ -261,13 +236,13 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
async with db.begin_nested() if db.in_transaction() else db.begin():
if expected_version is not None:
if not hasattr(settlement_db, 'version') or settlement_db.version != expected_version:
raise ConflictError( # Make sure ConflictError is defined
raise ConflictError(
f"Settlement (ID: {settlement_db.id}) cannot be deleted. "
f"Expected version {expected_version} does not match current version {settlement_db.version}. Please refresh."
)
await db.delete(settlement_db)
except ConflictError as e: # ConflictError should be defined
except ConflictError as e:
raise
except OperationalError as e:
logger.error(f"Database connection error during settlement deletion: {str(e)}", exc_info=True)
@ -275,7 +250,3 @@ async def delete_settlement(db: AsyncSession, settlement_db: SettlementModel, ex
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during settlement deletion: {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"DB transaction error during settlement deletion: {str(e)}")
# Ensure SettlementOperationError and ConflictError are defined in app.core.exceptions
# Example: class SettlementOperationError(AppException): pass
# Example: class ConflictError(AppException): status_code = 409

View File

@ -14,9 +14,7 @@ from app.models import (
ExpenseSplitStatusEnum,
ExpenseOverallStatusEnum,
)
# Placeholder for Pydantic schema - actual schema definition is a later step
# from app.schemas.settlement_activity import SettlementActivityCreate # Assuming this path
from pydantic import BaseModel # Using pydantic BaseModel directly for the placeholder
from pydantic import BaseModel
class SettlementActivityCreatePlaceholder(BaseModel):
@ -26,8 +24,7 @@ class SettlementActivityCreatePlaceholder(BaseModel):
paid_at: Optional[datetime] = None
class Config:
orm_mode = True # Pydantic V1 style orm_mode
# from_attributes = True # Pydantic V2 style
orm_mode = True
async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -> Optional[ExpenseSplit]:
@ -35,7 +32,6 @@ async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -
Updates the status of an ExpenseSplit based on its settlement activities.
Also updates the overall status of the parent Expense.
"""
# Fetch the ExpenseSplit with its related settlement_activities and the parent expense
result = await db.execute(
select(ExpenseSplit)
.options(
@ -47,18 +43,13 @@ async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -
expense_split = result.scalar_one_or_none()
if not expense_split:
# Or raise an exception, depending on desired error handling
return None
# Calculate total_paid from all settlement_activities for that split
total_paid = sum(activity.amount_paid for activity in expense_split.settlement_activities)
total_paid = Decimal(total_paid).quantize(Decimal("0.01")) # Ensure two decimal places
total_paid = Decimal(total_paid).quantize(Decimal("0.01"))
# Compare total_paid with ExpenseSplit.owed_amount
if total_paid >= expense_split.owed_amount:
expense_split.status = ExpenseSplitStatusEnum.paid
# Set paid_at to the latest relevant SettlementActivity or current time
# For simplicity, let's find the latest paid_at from activities, or use now()
latest_paid_at = None
if expense_split.settlement_activities:
latest_paid_at = max(act.paid_at for act in expense_split.settlement_activities if act.paid_at)
@ -66,13 +57,13 @@ async def update_expense_split_status(db: AsyncSession, expense_split_id: int) -
expense_split.paid_at = latest_paid_at if latest_paid_at else datetime.now(timezone.utc)
elif total_paid > 0:
expense_split.status = ExpenseSplitStatusEnum.partially_paid
expense_split.paid_at = None # Clear paid_at if not fully paid
expense_split.paid_at = None
else: # total_paid == 0
expense_split.status = ExpenseSplitStatusEnum.unpaid
expense_split.paid_at = None # Clear paid_at
expense_split.paid_at = None
await db.flush()
await db.refresh(expense_split, attribute_names=['status', 'paid_at', 'expense']) # Refresh to get updated data and related expense
await db.refresh(expense_split, attribute_names=['status', 'paid_at', 'expense'])
return expense_split
@ -81,18 +72,16 @@ async def update_expense_overall_status(db: AsyncSession, expense_id: int) -> Op
"""
Updates the overall_status of an Expense based on the status of its splits.
"""
# Fetch the Expense with its related splits
result = await db.execute(
select(Expense).options(selectinload(Expense.splits)).where(Expense.id == expense_id)
)
expense = result.scalar_one_or_none()
if not expense:
# Or raise an exception
return None
if not expense.splits: # No splits, should not happen for a valid expense but handle defensively
expense.overall_settlement_status = ExpenseOverallStatusEnum.unpaid # Or some other default/error state
if not expense.splits:
expense.overall_settlement_status = ExpenseOverallStatusEnum.unpaid
await db.flush()
await db.refresh(expense)
return expense
@ -107,14 +96,14 @@ async def update_expense_overall_status(db: AsyncSession, expense_id: int) -> Op
num_paid_splits += 1
elif split.status == ExpenseSplitStatusEnum.partially_paid:
num_partially_paid_splits += 1
else: # unpaid
else:
num_unpaid_splits += 1
if num_paid_splits == num_splits:
expense.overall_settlement_status = ExpenseOverallStatusEnum.paid
elif num_unpaid_splits == num_splits:
expense.overall_settlement_status = ExpenseOverallStatusEnum.unpaid
else: # Mix of paid, partially_paid, or unpaid but not all unpaid/paid
else:
expense.overall_settlement_status = ExpenseOverallStatusEnum.partially_paid
await db.flush()
@ -130,43 +119,33 @@ async def create_settlement_activity(
"""
Creates a new settlement activity, then updates the parent expense split and expense statuses.
"""
# Validate ExpenseSplit
split_result = await db.execute(select(ExpenseSplit).where(ExpenseSplit.id == settlement_activity_in.expense_split_id))
expense_split = split_result.scalar_one_or_none()
if not expense_split:
# Consider raising an HTTPException in an API layer
return None # ExpenseSplit not found
return None
# Validate User (paid_by_user_id)
user_result = await db.execute(select(User).where(User.id == settlement_activity_in.paid_by_user_id))
paid_by_user = user_result.scalar_one_or_none()
if not paid_by_user:
return None # User not found
# Create SettlementActivity instance
db_settlement_activity = SettlementActivity(
expense_split_id=settlement_activity_in.expense_split_id,
paid_by_user_id=settlement_activity_in.paid_by_user_id,
amount_paid=settlement_activity_in.amount_paid,
paid_at=settlement_activity_in.paid_at if settlement_activity_in.paid_at else datetime.now(timezone.utc),
created_by_user_id=current_user_id # The user recording the activity
created_by_user_id=current_user_id
)
db.add(db_settlement_activity)
await db.flush() # Flush to get the ID for db_settlement_activity
await db.flush()
# Update statuses
updated_split = await update_expense_split_status(db, expense_split_id=db_settlement_activity.expense_split_id)
if updated_split and updated_split.expense_id:
await update_expense_overall_status(db, expense_id=updated_split.expense_id)
else:
# This case implies update_expense_split_status returned None or expense_id was missing.
# This could be a problem, consider logging or raising an error.
# For now, the transaction would roll back if an exception is raised.
# If not raising, the overall status update might be skipped.
pass # Or handle error
await db.refresh(db_settlement_activity, attribute_names=['split', 'payer', 'creator']) # Refresh to load relationships
pass
return db_settlement_activity
@ -180,9 +159,9 @@ async def get_settlement_activity_by_id(
result = await db.execute(
select(SettlementActivity)
.options(
selectinload(SettlementActivity.split).selectinload(ExpenseSplit.expense), # Load split and its parent expense
selectinload(SettlementActivity.payer), # Load the user who paid
selectinload(SettlementActivity.creator) # Load the user who created the record
selectinload(SettlementActivity.split).selectinload(ExpenseSplit.expense),
selectinload(SettlementActivity.payer),
selectinload(SettlementActivity.creator)
)
.where(SettlementActivity.id == settlement_activity_id)
)
@ -199,8 +178,8 @@ async def get_settlement_activities_for_split(
select(SettlementActivity)
.where(SettlementActivity.expense_split_id == expense_split_id)
.options(
selectinload(SettlementActivity.payer), # Load the user who paid
selectinload(SettlementActivity.creator) # Load the user who created the record
selectinload(SettlementActivity.payer),
selectinload(SettlementActivity.creator)
)
.order_by(SettlementActivity.paid_at.desc(), SettlementActivity.created_at.desc())
.offset(skip)

View File

@ -1,12 +1,11 @@
# app/crud/user.py
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy.orm import selectinload # Ensure selectinload is imported
from sqlalchemy.orm import selectinload
from sqlalchemy.exc import SQLAlchemyError, IntegrityError, OperationalError
from typing import Optional
import logging # Add logging import
import logging
from app.models import User as UserModel, UserGroup as UserGroupModel, Group as GroupModel # Import related models for selectinload
from app.models import User as UserModel, UserGroup as UserGroupModel
from app.schemas.user import UserCreate
from app.core.security import hash_password
from app.core.exceptions import (
@ -16,23 +15,19 @@ from app.core.exceptions import (
DatabaseIntegrityError,
DatabaseQueryError,
DatabaseTransactionError,
UserOperationError # Add if specific user operation errors are needed
UserOperationError
)
logger = logging.getLogger(__name__) # Initialize logger
logger = logging.getLogger(__name__)
async def get_user_by_email(db: AsyncSession, email: str) -> Optional[UserModel]:
"""Fetches a user from the database by email, with common relationships."""
try:
# db.begin() is not strictly necessary for a single read, but ensures atomicity if multiple reads were added.
# For a single select, it can be omitted if preferred, session handles connection.
stmt = (
select(UserModel)
.filter(UserModel.email == email)
.options(
selectinload(UserModel.group_associations).selectinload(UserGroupModel.group), # Groups user is member of
selectinload(UserModel.created_groups) # Groups user created
# Add other relationships as needed by UserPublic schema
selectinload(UserModel.group_associations).selectinload(UserGroupModel.group),
)
)
result = await db.execute(stmt)
@ -51,27 +46,25 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
_hashed_password = hash_password(user_in.password)
db_user = UserModel(
email=user_in.email,
hashed_password=_hashed_password, # Field name in model is hashed_password
hashed_password=_hashed_password,
name=user_in.name
)
db.add(db_user)
await db.flush() # Flush to get DB-generated values like ID
await db.flush()
# Re-fetch with relationships
stmt = (
select(UserModel)
.where(UserModel.id == db_user.id)
.options(
selectinload(UserModel.group_associations).selectinload(UserGroupModel.group),
selectinload(UserModel.created_groups)
# Add other relationships as needed by UserPublic schema
)
)
result = await db.execute(stmt)
loaded_user = result.scalar_one_or_none()
if loaded_user is None:
raise UserOperationError("Failed to load user after creation.") # Define UserOperationError
raise UserOperationError("Failed to load user after creation.")
return loaded_user
except IntegrityError as e:
@ -84,7 +77,4 @@ async def create_user(db: AsyncSession, user_in: UserCreate) -> UserModel:
raise DatabaseConnectionError(f"Database connection error during user creation: {str(e)}")
except SQLAlchemyError as e:
logger.error(f"Unexpected SQLAlchemy error during user creation for email '{user_in.email}': {str(e)}", exc_info=True)
raise DatabaseTransactionError(f"Failed to create user due to other DB error: {str(e)}")
# Ensure UserOperationError is defined in app.core.exceptions if used
# Example: class UserOperationError(AppException): pass
raise DatabaseTransactionError(f"Failed to create user due to other DB error: {str(e)}")

View File

@ -1,24 +1,18 @@
# app/database.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base
from app.config import settings
# Ensure DATABASE_URL is set before proceeding
if not settings.DATABASE_URL:
raise ValueError("DATABASE_URL is not configured in settings.")
# Create the SQLAlchemy async engine
# pool_recycle=3600 helps prevent stale connections on some DBs
engine = create_async_engine(
settings.DATABASE_URL,
echo=False, # Disable SQL query logging for production (use DEBUG log level to enable)
future=True, # Use SQLAlchemy 2.0 style features
pool_recycle=3600, # Optional: recycle connections after 1 hour
pool_pre_ping=True # Add this line to ensure connections are live
echo=False,
future=True,
pool_recycle=3600,
pool_pre_ping=True
)
# Create a configured "Session" class
# expire_on_commit=False prevents attributes from expiring after commit
AsyncSessionLocal = sessionmaker(
bind=engine,
class_=AsyncSession,
@ -27,10 +21,8 @@ AsyncSessionLocal = sessionmaker(
autocommit=False,
)
# Base class for our ORM models
Base = declarative_base()
# Dependency to get DB session in path operations
async def get_session() -> AsyncSession: # type: ignore
"""
Dependency function that yields an AsyncSession for read-only operations.
@ -38,7 +30,6 @@ async def get_session() -> AsyncSession: # type: ignore
"""
async with AsyncSessionLocal() as session:
yield session
# The 'async with' block handles session.close() automatically.
async def get_transactional_session() -> AsyncSession: # type: ignore
"""
@ -51,7 +42,5 @@ async def get_transactional_session() -> AsyncSession: # type: ignore
async with AsyncSessionLocal() as session:
async with session.begin():
yield session
# Transaction is automatically committed on success or rolled back on exception
# Alias for backward compatibility
get_db = get_session

View File

@ -1,4 +1,2 @@
from app.database import AsyncSessionLocal
# Export the async session factory
async_session = AsyncSessionLocal

View File

@ -15,18 +15,15 @@ async def generate_recurring_expenses(db: AsyncSession) -> None:
Should be run daily to check for and create new recurring expenses.
"""
try:
# Get all active recurring expenses that need to be generated
now = datetime.utcnow()
query = select(Expense).join(RecurrencePattern).where(
and_(
Expense.is_recurring == True,
Expense.next_occurrence <= now,
# Check if we haven't reached max occurrences
(
(RecurrencePattern.max_occurrences == None) |
(RecurrencePattern.max_occurrences > 0)
),
# Check if we haven't reached end date
(
(RecurrencePattern.end_date == None) |
(RecurrencePattern.end_date > now)
@ -54,12 +51,10 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
if not pattern:
return
# Calculate next occurrence date
next_date = _calculate_next_occurrence(expense.next_occurrence, pattern)
if not next_date:
return
# Create new expense based on template
new_expense = ExpenseCreate(
description=expense.description,
total_amount=expense.total_amount,
@ -70,14 +65,12 @@ async def _generate_next_occurrence(db: AsyncSession, expense: Expense) -> None:
group_id=expense.group_id,
item_id=expense.item_id,
paid_by_user_id=expense.paid_by_user_id,
is_recurring=False, # Generated expenses are not recurring
splits_in=None # Will be generated based on split_type
is_recurring=False,
splits_in=None
)
# Create the new expense
created_expense = await create_expense(db, new_expense, expense.created_by_user_id)
# Update the original expense
expense.last_occurrence = next_date
expense.next_occurrence = _calculate_next_occurrence(next_date, pattern)
@ -98,7 +91,6 @@ def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePatter
if not pattern.days_of_week:
return current_date + timedelta(weeks=pattern.interval)
# Find next day of week
current_weekday = current_date.weekday()
next_weekday = min((d for d in pattern.days_of_week if d > current_weekday),
default=min(pattern.days_of_week))
@ -108,7 +100,6 @@ def _calculate_next_occurrence(current_date: datetime, pattern: RecurrencePatter
return current_date + timedelta(days=days_ahead)
elif pattern.type == 'monthly':
# Add months to current date
year = current_date.year + (current_date.month + pattern.interval - 1) // 12
month = (current_date.month + pattern.interval - 1) % 12 + 1
return current_date.replace(year=year, month=month)

View File

@ -1,60 +1,36 @@
# app/main.py
import logging
import uvicorn
from fastapi import FastAPI, HTTPException, Depends, status, Request
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
import sentry_sdk
from sentry_sdk.integrations.fastapi import FastApiIntegration
from fastapi_users.authentication import JWTStrategy
from pydantic import BaseModel
from jose import jwt, JWTError
from sqlalchemy.ext.asyncio import AsyncEngine
from alembic.config import Config
from alembic import command
import os
import sys
from app.api.api_router import api_router
from app.config import settings
from app.core.api_config import API_METADATA, API_TAGS
from app.auth import fastapi_users, auth_backend, get_refresh_jwt_strategy, get_jwt_strategy
from app.models import User
from app.api.auth.oauth import router as oauth_router
from app.auth import fastapi_users, auth_backend
from app.schemas.user import UserPublic, UserCreate, UserUpdate
from app.core.scheduler import init_scheduler, shutdown_scheduler
from app.database import get_session
from sqlalchemy import select
# Response model for refresh endpoint
class RefreshResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
# Initialize Sentry only if DSN is provided
if settings.SENTRY_DSN:
sentry_sdk.init(
dsn=settings.SENTRY_DSN,
integrations=[
FastApiIntegration(),
],
# Adjust traces_sample_rate for production
traces_sample_rate=0.1 if settings.is_production else 1.0,
environment=settings.ENVIRONMENT,
# Enable PII data only in development
send_default_pii=not settings.is_production
)
# --- Logging Setup ---
logging.basicConfig(
level=getattr(logging, settings.LOG_LEVEL),
format=settings.LOG_FORMAT
)
logger = logging.getLogger(__name__)
# --- FastAPI App Instance ---
# Create API metadata with environment-dependent settings
api_metadata = {
**API_METADATA,
"docs_url": settings.docs_url,
@ -67,13 +43,11 @@ app = FastAPI(
openapi_tags=API_TAGS
)
# Add session middleware for OAuth
app.add_middleware(
SessionMiddleware,
secret_key=settings.SESSION_SECRET_KEY
)
# --- CORS Middleware ---
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
@ -82,82 +56,7 @@ app.add_middleware(
allow_headers=["*"],
expose_headers=["*"]
)
# --- End CORS Middleware ---
# Refresh token endpoint
@app.post("/auth/jwt/refresh", response_model=RefreshResponse, tags=["auth"])
async def refresh_jwt_token(
request: Request,
refresh_strategy: JWTStrategy = Depends(get_refresh_jwt_strategy),
access_strategy: JWTStrategy = Depends(get_jwt_strategy),
):
"""
Refresh access token using a valid refresh token.
Send refresh token in Authorization header: Bearer <refresh_token>
"""
try:
# Get refresh token from Authorization header
authorization = request.headers.get("Authorization")
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token missing or invalid format",
headers={"WWW-Authenticate": "Bearer"},
)
refresh_token = authorization.split(" ")[1]
# Validate refresh token and get user data
try:
# Decode the refresh token to get the user identifier
payload = jwt.decode(refresh_token, settings.SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token",
)
# Get user from database
async with get_session() as session:
result = await session.execute(select(User).where(User.id == int(user_id)))
user = result.scalar_one_or_none()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive",
)
# Generate new tokens
new_access_token = await access_strategy.write_token(user)
new_refresh_token = await refresh_strategy.write_token(user)
return RefreshResponse(
access_token=new_access_token,
refresh_token=new_refresh_token,
token_type="bearer"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error refreshing token: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token"
)
# --- Include API Routers ---
# Include OAuth routes first (no auth required)
app.include_router(oauth_router, prefix="/auth", tags=["auth"])
# Include FastAPI-Users routes
app.include_router(
fastapi_users.get_auth_router(auth_backend),
prefix="/auth/jwt",
@ -184,11 +83,8 @@ app.include_router(
tags=["users"],
)
# Include your API router
app.include_router(api_router, prefix=settings.API_PREFIX)
# --- End Include API Routers ---
# Health check endpoint
@app.get("/health", tags=["Health"])
async def health_check():
"""
@ -200,7 +96,6 @@ async def health_check():
"version": settings.API_VERSION
}
# --- Root Endpoint (Optional - outside the main API structure) ---
@app.get("/", tags=["Root"])
async def read_root():
"""
@ -213,21 +108,17 @@ async def read_root():
"environment": settings.ENVIRONMENT,
"version": settings.API_VERSION
}
# --- End Root Endpoint ---
async def run_migrations():
"""Run database migrations."""
try:
logger.info("Running database migrations...")
# Get the absolute path to the alembic directory
base_path = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
alembic_path = os.path.join(base_path, 'alembic')
# Add alembic directory to Python path
if alembic_path not in sys.path:
sys.path.insert(0, alembic_path)
# Import and run migrations
from migrations import run_migrations as run_db_migrations
await run_db_migrations()
@ -240,11 +131,7 @@ async def run_migrations():
async def startup_event():
"""Initialize services on startup."""
logger.info(f"Application startup in {settings.ENVIRONMENT} environment...")
# Run database migrations
# await run_migrations()
# Initialize scheduler
init_scheduler()
logger.info("Application startup complete.")
@ -252,15 +139,5 @@ async def startup_event():
async def shutdown_event():
"""Cleanup services on shutdown."""
logger.info("Application shutdown: Disconnecting from database...")
# await database.engine.dispose() # Close connection pool
shutdown_scheduler()
logger.info("Application shutdown complete.")
# --- End Events ---
# --- Direct Run (for simple local testing if needed) ---
# It's better to use `uvicorn app.main:app --reload` from the terminal
# if __name__ == "__main__":
# logger.info("Starting Uvicorn server directly from main.py")
# uvicorn.run(app, host="0.0.0.0", port=8000)
# ------------------------------------------------------
logger.info("Application shutdown complete.")

View File

@ -1,4 +1,3 @@
# app/models.py
import enum
import secrets
from datetime import datetime, timedelta, timezone
@ -14,16 +13,14 @@ from sqlalchemy import (
UniqueConstraint,
Index,
DDL,
event,
delete,
func,
text as sa_text,
Text, # <-- Add Text for description
Numeric, # <-- Add Numeric for price
Text,
Numeric,
CheckConstraint,
Date # Added Date for Chore model
Date
)
from sqlalchemy.orm import relationship, backref
from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import JSONB
from .database import Base
@ -82,7 +79,6 @@ class ChoreHistoryEventTypeEnum(str, enum.Enum):
UNASSIGNED = "unassigned"
REASSIGNED = "reassigned"
SCHEDULE_GENERATED = "schedule_generated"
# Add more specific events as needed
DUE_DATE_CHANGED = "due_date_changed"
DETAILS_CHANGED = "details_changed"
@ -103,34 +99,20 @@ class User(Base):
created_groups = relationship("Group", back_populates="creator")
group_associations = relationship("UserGroup", back_populates="user", cascade="all, delete-orphan")
created_invites = relationship("Invite", back_populates="creator")
# --- NEW Relationships for Lists/Items ---
created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator") # Link List.created_by_id -> User
added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user") # Link Item.added_by_id -> User
completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user") # Link Item.completed_by_id -> User
# --- End NEW Relationships ---
# --- Relationships for Cost Splitting ---
created_lists = relationship("List", foreign_keys="List.created_by_id", back_populates="creator")
added_items = relationship("Item", foreign_keys="Item.added_by_id", back_populates="added_by_user")
completed_items = relationship("Item", foreign_keys="Item.completed_by_id", back_populates="completed_by_user")
expenses_paid = relationship("Expense", foreign_keys="Expense.paid_by_user_id", back_populates="paid_by_user", cascade="all, delete-orphan")
expenses_created = relationship("Expense", foreign_keys="Expense.created_by_user_id", back_populates="created_by_user", cascade="all, delete-orphan")
expense_splits = relationship("ExpenseSplit", foreign_keys="ExpenseSplit.user_id", back_populates="user", cascade="all, delete-orphan")
settlements_made = relationship("Settlement", foreign_keys="Settlement.paid_by_user_id", back_populates="payer", cascade="all, delete-orphan")
settlements_received = relationship("Settlement", foreign_keys="Settlement.paid_to_user_id", back_populates="payee", cascade="all, delete-orphan")
settlements_created = relationship("Settlement", foreign_keys="Settlement.created_by_user_id", back_populates="created_by_user", cascade="all, delete-orphan")
# --- End Relationships for Cost Splitting ---
# --- Relationships for Chores ---
created_chores = relationship("Chore", foreign_keys="[Chore.created_by_id]", back_populates="creator")
assigned_chores = relationship("ChoreAssignment", back_populates="assigned_user", cascade="all, delete-orphan")
# --- End Relationships for Chores ---
# --- History Relationships ---
chore_history_entries = relationship("ChoreHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
assignment_history_entries = relationship("ChoreAssignmentHistory", back_populates="changed_by_user", cascade="all, delete-orphan")
# --- End History Relationships ---
# --- Group Model ---
class Group(Base):
__tablename__ = "groups"
@ -139,30 +121,16 @@ class Group(Base):
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
creator = relationship("User", back_populates="created_groups")
member_associations = relationship("UserGroup", back_populates="group", cascade="all, delete-orphan")
invites = relationship("Invite", back_populates="group", cascade="all, delete-orphan")
# --- NEW Relationship for Lists ---
lists = relationship("List", back_populates="group", cascade="all, delete-orphan") # Link List.group_id -> Group
# --- End NEW Relationship ---
# --- Relationships for Cost Splitting ---
lists = relationship("List", back_populates="group", cascade="all, delete-orphan")
expenses = relationship("Expense", foreign_keys="Expense.group_id", back_populates="group", cascade="all, delete-orphan")
settlements = relationship("Settlement", foreign_keys="Settlement.group_id", back_populates="group", cascade="all, delete-orphan")
# --- End Relationships for Cost Splitting ---
# --- Relationship for Chores ---
chores = relationship("Chore", back_populates="group", cascade="all, delete-orphan")
# --- End Relationship for Chores ---
# --- History Relationships ---
chore_history = relationship("ChoreHistory", back_populates="group", cascade="all, delete-orphan")
# --- End History Relationships ---
# --- UserGroup Association Model ---
class UserGroup(Base):
__tablename__ = "user_groups"
__table_args__ = (UniqueConstraint('user_id', 'group_id', name='uq_user_group'),)
@ -176,8 +144,6 @@ class UserGroup(Base):
user = relationship("User", back_populates="group_associations")
group = relationship("Group", back_populates="member_associations")
# --- Invite Model ---
class Invite(Base):
__tablename__ = "invites"
__table_args__ = (
@ -196,36 +162,30 @@ class Invite(Base):
creator = relationship("User", back_populates="created_invites")
# === NEW: List Model ===
class List(Base):
__tablename__ = "lists"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True, nullable=False)
description = Column(Text, nullable=True)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who created this list
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) # Which group it belongs to (NULL if personal)
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True)
is_complete = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1')
# --- Relationships ---
creator = relationship("User", back_populates="created_lists") # Link to User.created_lists
group = relationship("Group", back_populates="lists") # Link to Group.lists
creator = relationship("User", back_populates="created_lists")
group = relationship("Group", back_populates="lists")
items = relationship(
"Item",
back_populates="list",
cascade="all, delete-orphan",
order_by="Item.position.asc(), Item.created_at.asc()" # Default order by position, then creation
order_by="Item.position.asc(), Item.created_at.asc()"
)
# --- Relationships for Cost Splitting ---
expenses = relationship("Expense", foreign_keys="Expense.list_id", back_populates="list", cascade="all, delete-orphan")
# --- End Relationships for Cost Splitting ---
# === NEW: Item Model ===
class Item(Base):
__tablename__ = "items"
__table_args__ = (
@ -233,31 +193,24 @@ class Item(Base):
)
id = Column(Integer, primary_key=True, index=True)
list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False) # Belongs to which list
list_id = Column(Integer, ForeignKey("lists.id", ondelete="CASCADE"), nullable=False)
name = Column(String, index=True, nullable=False)
quantity = Column(String, nullable=True) # Flexible quantity (e.g., "1", "2 lbs", "a bunch")
quantity = Column(String, nullable=True)
is_complete = Column(Boolean, default=False, nullable=False)
price = Column(Numeric(10, 2), nullable=True) # For cost splitting later (e.g., 12345678.99)
position = Column(Integer, nullable=False, server_default='0') # For ordering
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False) # Who added this item
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Who marked it complete
price = Column(Numeric(10, 2), nullable=True)
position = Column(Integer, nullable=False, server_default='0')
added_by_id = Column(Integer, ForeignKey("users.id"), nullable=False)
completed_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1')
# --- Relationships ---
list = relationship("List", back_populates="items") # Link to List.items
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items") # Link to User.added_items
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items") # Link to User.completed_items
# --- Relationships for Cost Splitting ---
# If an item directly results in an expense, or an expense can be tied to an item.
expenses = relationship("Expense", back_populates="item") # An item might have multiple associated expenses
# --- End Relationships for Cost Splitting ---
# === NEW Models for Advanced Cost Splitting ===
list = relationship("List", back_populates="items")
added_by_user = relationship("User", foreign_keys=[added_by_id], back_populates="added_items")
completed_by_user = relationship("User", foreign_keys=[completed_by_id], back_populates="completed_items")
expenses = relationship("Expense", back_populates="item")
class Expense(Base):
__tablename__ = "expenses"
@ -268,7 +221,6 @@ class Expense(Base):
expense_date = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
split_type = Column(SAEnum(SplitTypeEnum, name="splittypeenum", create_type=True), nullable=False)
# Foreign Keys
list_id = Column(Integer, ForeignKey("lists.id"), nullable=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True, index=True)
item_id = Column(Integer, ForeignKey("items.id"), nullable=True)
@ -279,7 +231,6 @@ class Expense(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1')
# Relationships
paid_by_user = relationship("User", foreign_keys=[paid_by_user_id], back_populates="expenses_paid")
created_by_user = relationship("User", foreign_keys=[created_by_user_id], back_populates="expenses_created")
list = relationship("List", foreign_keys=[list_id], back_populates="expenses")
@ -289,7 +240,6 @@ class Expense(Base):
parent_expense = relationship("Expense", remote_side=[id], back_populates="child_expenses")
child_expenses = relationship("Expense", back_populates="parent_expense")
overall_settlement_status = Column(SAEnum(ExpenseOverallStatusEnum, name="expenseoverallstatusenum", create_type=True), nullable=False, server_default=ExpenseOverallStatusEnum.unpaid.value, default=ExpenseOverallStatusEnum.unpaid)
# --- Recurrence fields ---
is_recurring = Column(Boolean, default=False, nullable=False)
recurrence_pattern_id = Column(Integer, ForeignKey("recurrence_patterns.id"), nullable=True)
recurrence_pattern = relationship("RecurrencePattern", back_populates="expenses", uselist=False) # One-to-one
@ -298,7 +248,6 @@ class Expense(Base):
last_occurrence = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
# Ensure at least one context is provided
CheckConstraint('(item_id IS NOT NULL) OR (list_id IS NOT NULL) OR (group_id IS NOT NULL)', name='chk_expense_context'),
)
@ -320,14 +269,12 @@ class ExpenseSplit(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationships
expense = relationship("Expense", back_populates="splits")
user = relationship("User", foreign_keys=[user_id], back_populates="expense_splits")
settlement_activities = relationship("SettlementActivity", back_populates="split", cascade="all, delete-orphan")
# New fields for tracking payment status
status = Column(SAEnum(ExpenseSplitStatusEnum, name="expensesplitstatusenum", create_type=True), nullable=False, server_default=ExpenseSplitStatusEnum.unpaid.value, default=ExpenseSplitStatusEnum.unpaid)
paid_at = Column(DateTime(timezone=True), nullable=True) # Timestamp when the split was fully paid
paid_at = Column(DateTime(timezone=True), nullable=True)
class Settlement(Base):
__tablename__ = "settlements"
@ -345,33 +292,28 @@ class Settlement(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
version = Column(Integer, nullable=False, default=1, server_default='1')
# Relationships
group = relationship("Group", foreign_keys=[group_id], back_populates="settlements")
payer = relationship("User", foreign_keys=[paid_by_user_id], back_populates="settlements_made")
payee = relationship("User", foreign_keys=[paid_to_user_id], back_populates="settlements_received")
created_by_user = relationship("User", foreign_keys=[created_by_user_id], back_populates="settlements_created")
__table_args__ = (
# Ensure payer and payee are different users
CheckConstraint('paid_by_user_id != paid_to_user_id', name='chk_settlement_different_users'),
)
# Potential future: PaymentMethod model, etc.
class SettlementActivity(Base):
__tablename__ = "settlement_activities"
id = Column(Integer, primary_key=True, index=True)
expense_split_id = Column(Integer, ForeignKey("expense_splits.id"), nullable=False, index=True)
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) # User who made this part of the payment
paid_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
paid_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
amount_paid = Column(Numeric(10, 2), nullable=False)
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) # User who recorded this activity
created_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# --- Relationships ---
split = relationship("ExpenseSplit", back_populates="settlement_activities")
payer = relationship("User", foreign_keys=[paid_by_user_id], backref="made_settlement_activities")
creator = relationship("User", foreign_keys=[created_by_user_id], backref="created_settlement_activities")
@ -395,15 +337,14 @@ class Chore(Base):
created_by_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
frequency = Column(SAEnum(ChoreFrequencyEnum, name="chorefrequencyenum", create_type=True), nullable=False)
custom_interval_days = Column(Integer, nullable=True) # Only if frequency is 'custom'
custom_interval_days = Column(Integer, nullable=True)
next_due_date = Column(Date, nullable=False) # Changed to Date
next_due_date = Column(Date, nullable=False)
last_completed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# --- Relationships ---
group = relationship("Group", back_populates="chores")
creator = relationship("User", back_populates="created_chores")
assignments = relationship("ChoreAssignment", back_populates="chore", cascade="all, delete-orphan")
@ -418,14 +359,13 @@ class ChoreAssignment(Base):
chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=False, index=True)
assigned_to_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
due_date = Column(Date, nullable=False) # Specific due date for this instance, changed to Date
due_date = Column(Date, nullable=False)
is_complete = Column(Boolean, default=False, nullable=False)
completed_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# --- Relationships ---
chore = relationship("Chore", back_populates="assignments")
assigned_user = relationship("User", back_populates="assigned_chores")
history = relationship("ChoreAssignmentHistory", back_populates="assignment", cascade="all, delete-orphan")
@ -437,21 +377,14 @@ class RecurrencePattern(Base):
id = Column(Integer, primary_key=True, index=True)
type = Column(SAEnum(RecurrenceTypeEnum, name="recurrencetypeenum", create_type=True), nullable=False)
interval = Column(Integer, default=1, nullable=False) # e.g., every 1 day, every 2 weeks
days_of_week = Column(String, nullable=True) # For weekly recurrences, e.g., "MON,TUE,FRI"
# day_of_month = Column(Integer, nullable=True) # For monthly on a specific day
# week_of_month = Column(Integer, nullable=True) # For monthly on a specific week (e.g., 2nd week)
# month_of_year = Column(Integer, nullable=True) # For yearly recurrences
interval = Column(Integer, default=1, nullable=False)
days_of_week = Column(String, nullable=True)
end_date = Column(DateTime(timezone=True), nullable=True)
max_occurrences = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
# Relationship back to Expenses that use this pattern (could be one-to-many if patterns are shared)
# However, the current CRUD implies one RecurrencePattern per Expense if recurring.
# If a pattern can be shared, this would be a one-to-many (RecurrencePattern to many Expenses).
# For now, assuming one-to-one as implied by current Expense.recurrence_pattern relationship setup.
expenses = relationship("Expense", back_populates="recurrence_pattern")
@ -464,13 +397,12 @@ class ChoreHistory(Base):
id = Column(Integer, primary_key=True, index=True)
chore_id = Column(Integer, ForeignKey("chores.id", ondelete="CASCADE"), nullable=True, index=True)
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True) # For group-level events
group_id = Column(Integer, ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True)
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False)
event_data = Column(JSONB, nullable=True) # e.g., {'field': 'name', 'old': 'Old', 'new': 'New'}
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Nullable if system-generated
event_data = Column(JSONB, nullable=True)
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
chore = relationship("Chore", back_populates="history")
group = relationship("Group", back_populates="chore_history")
changed_by_user = relationship("User", back_populates="chore_history_entries")
@ -480,11 +412,10 @@ class ChoreAssignmentHistory(Base):
id = Column(Integer, primary_key=True, index=True)
assignment_id = Column(Integer, ForeignKey("chore_assignments.id", ondelete="CASCADE"), nullable=False, index=True)
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False) # Reusing enum
event_type = Column(SAEnum(ChoreHistoryEventTypeEnum, name="chorehistoryeventtypeenum", create_type=True), nullable=False)
event_data = Column(JSONB, nullable=True)
changed_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# --- Relationships ---
assignment = relationship("ChoreAssignment", back_populates="history")
changed_by_user = relationship("User", back_populates="assignment_history_entries")

View File

@ -1,13 +1,7 @@
# app/schemas/auth.py
from pydantic import BaseModel, EmailStr
from pydantic import BaseModel
from app.config import settings
class Token(BaseModel):
access_token: str
refresh_token: str # Added refresh token
token_type: str = settings.TOKEN_TYPE # Use configured token type
# Optional: If you preferred not to use OAuth2PasswordRequestForm
# class UserLogin(BaseModel):
# email: EmailStr
# password: str
refresh_token: str
token_type: str = settings.TOKEN_TYPE

View File

@ -1,18 +1,12 @@
from datetime import date, datetime
from typing import Optional, List, Any
from pydantic import BaseModel, ConfigDict, field_validator
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, ChoreHistoryEventTypeEnum
from .user import UserPublic
# Assuming ChoreFrequencyEnum is imported from models
# Adjust the import path if necessary based on your project structure.
# e.g., from app.models import ChoreFrequencyEnum
from ..models import ChoreFrequencyEnum, ChoreTypeEnum, User as UserModel, ChoreHistoryEventTypeEnum # For UserPublic relation
from .user import UserPublic # For embedding user information
# Forward declaration for circular dependencies
class ChoreAssignmentPublic(BaseModel):
pass
# History Schemas
class ChoreHistoryPublic(BaseModel):
id: int
event_type: ChoreHistoryEventTypeEnum
@ -32,7 +26,6 @@ class ChoreAssignmentHistoryPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
# Chore Schemas
class ChoreBase(BaseModel):
name: str
description: Optional[str] = None

View File

@ -4,10 +4,10 @@ from decimal import Decimal
class UserCostShare(BaseModel):
user_id: int
user_identifier: str # Name or email
items_added_value: Decimal = Decimal("0.00") # Total value of items this user added
amount_due: Decimal # The user's share of the total cost (for equal split, this is total_cost / num_users)
balance: Decimal # items_added_value - amount_due
user_identifier: str
items_added_value: Decimal = Decimal("0.00")
amount_due: Decimal
balance: Decimal
model_config = ConfigDict(from_attributes=True)
@ -23,19 +23,19 @@ class ListCostSummary(BaseModel):
class UserBalanceDetail(BaseModel):
user_id: int
user_identifier: str # Name or email
user_identifier: str
total_paid_for_expenses: Decimal = Decimal("0.00")
total_share_of_expenses: Decimal = Decimal("0.00")
total_settlements_paid: Decimal = Decimal("0.00")
total_settlements_received: Decimal = Decimal("0.00")
net_balance: Decimal = Decimal("0.00") # (paid_for_expenses + settlements_received) - (share_of_expenses + settlements_paid)
net_balance: Decimal = Decimal("0.00")
model_config = ConfigDict(from_attributes=True)
class SuggestedSettlement(BaseModel):
from_user_id: int
from_user_identifier: str # Name or email of payer
from_user_identifier: str
to_user_id: int
to_user_identifier: str # Name or email of payee
to_user_identifier: str
amount: Decimal
model_config = ConfigDict(from_attributes=True)
@ -45,11 +45,5 @@ class GroupBalanceSummary(BaseModel):
overall_total_expenses: Decimal = Decimal("0.00")
overall_total_settlements: Decimal = Decimal("0.00")
user_balances: List[UserBalanceDetail]
# Optional: Could add a list of suggested settlements to zero out balances
suggested_settlements: Optional[List[SuggestedSettlement]] = None
model_config = ConfigDict(from_attributes=True)
# class SuggestedSettlement(BaseModel):
# from_user_id: int
# to_user_id: int
# amount: Decimal
model_config = ConfigDict(from_attributes=True)

View File

@ -1,19 +1,11 @@
# app/schemas/expense.py
from pydantic import BaseModel, ConfigDict, validator, Field
from typing import List, Optional, Dict, Any
from typing import List, Optional
from decimal import Decimal
from datetime import datetime
from app.models import SplitTypeEnum, ExpenseSplitStatusEnum, ExpenseOverallStatusEnum
from app.schemas.user import UserPublic
from app.schemas.settlement_activity import SettlementActivityPublic
# Assuming SplitTypeEnum is accessible here, e.g., from app.models or app.core.enums
# For now, let's redefine it or import it if models.py is parsable by Pydantic directly
# If it's from app.models, you might need to make app.models.SplitTypeEnum Pydantic-compatible or map it.
# For simplicity during schema definition, I'll redefine a string enum here.
# In a real setup, ensure this aligns with the SQLAlchemy enum in models.py.
from app.models import SplitTypeEnum, ExpenseSplitStatusEnum, ExpenseOverallStatusEnum # Try importing directly
from app.schemas.user import UserPublic # For user details in responses
from app.schemas.settlement_activity import SettlementActivityPublic # For settlement activities
# --- ExpenseSplit Schemas ---
class ExpenseSplitBase(BaseModel):
user_id: int
owed_amount: Decimal
@ -21,20 +13,19 @@ class ExpenseSplitBase(BaseModel):
share_units: Optional[int] = None
class ExpenseSplitCreate(ExpenseSplitBase):
pass # All fields from base are needed for creation
pass
class ExpenseSplitPublic(ExpenseSplitBase):
id: int
expense_id: int
user: Optional[UserPublic] = None # If we want to nest user details
user: Optional[UserPublic] = None
created_at: datetime
updated_at: datetime
status: ExpenseSplitStatusEnum # New field
paid_at: Optional[datetime] = None # New field
settlement_activities: List[SettlementActivityPublic] = [] # New field
status: ExpenseSplitStatusEnum
paid_at: Optional[datetime] = None
settlement_activities: List[SettlementActivityPublic] = []
model_config = ConfigDict(from_attributes=True)
# --- Expense Schemas ---
class RecurrencePatternBase(BaseModel):
type: str = Field(..., description="Type of recurrence: daily, weekly, monthly, yearly")
interval: int = Field(..., description="Interval of recurrence (e.g., every X days/weeks/months/years)")
@ -63,16 +54,13 @@ class ExpenseBase(BaseModel):
expense_date: Optional[datetime] = None
split_type: SplitTypeEnum
list_id: Optional[int] = None
group_id: Optional[int] = None # Should be present if list_id is not, and vice-versa
group_id: Optional[int] = None
item_id: Optional[int] = None
paid_by_user_id: int
is_recurring: bool = Field(False, description="Whether this is a recurring expense")
recurrence_pattern: Optional[RecurrencePatternCreate] = Field(None, description="Recurrence pattern for recurring expenses")
class ExpenseCreate(ExpenseBase):
# For EQUAL split, splits are generated. For others, they might be provided.
# This logic will be in the CRUD: if split_type is EXACT_AMOUNTS, PERCENTAGE, SHARES,
# then 'splits_in' should be provided.
splits_in: Optional[List[ExpenseSplitCreate]] = None
@validator('total_amount')
@ -81,8 +69,6 @@ class ExpenseCreate(ExpenseBase):
raise ValueError('Total amount must be positive')
return v
# Basic validation: if list_id is None, group_id must be provided.
# More complex cross-field validation might be needed.
@validator('group_id', always=True)
def check_list_or_group_id(cls, v, values):
if values.get('list_id') is None and v is None:
@ -105,10 +91,8 @@ class ExpenseUpdate(BaseModel):
split_type: Optional[SplitTypeEnum] = None
list_id: Optional[int] = None
group_id: Optional[int] = None
item_id: Optional[int] = None
# paid_by_user_id is usually not updatable directly to maintain integrity.
# Updating splits would be a more complex operation, potentially a separate endpoint or careful logic.
version: int # For optimistic locking
item_id: Optional[int] = None
version: int
is_recurring: Optional[bool] = None
recurrence_pattern: Optional[RecurrencePatternUpdate] = None
next_occurrence: Optional[datetime] = None
@ -120,11 +104,8 @@ class ExpensePublic(ExpenseBase):
version: int
created_by_user_id: int
splits: List[ExpenseSplitPublic] = []
paid_by_user: Optional[UserPublic] = None # If nesting user details
overall_settlement_status: ExpenseOverallStatusEnum # New field
# list: Optional[ListPublic] # If nesting list details
# group: Optional[GroupPublic] # If nesting group details
# item: Optional[ItemPublic] # If nesting item details
paid_by_user: Optional[UserPublic] = None
overall_settlement_status: ExpenseOverallStatusEnum
is_recurring: bool
next_occurrence: Optional[datetime]
last_occurrence: Optional[datetime]
@ -133,7 +114,6 @@ class ExpensePublic(ExpenseBase):
generated_expenses: List['ExpensePublic'] = []
model_config = ConfigDict(from_attributes=True)
# --- Settlement Schemas ---
class SettlementBase(BaseModel):
group_id: int
paid_by_user_id: int
@ -159,8 +139,7 @@ class SettlementUpdate(BaseModel):
amount: Optional[Decimal] = None
settlement_date: Optional[datetime] = None
description: Optional[str] = None
# group_id, paid_by_user_id, paid_to_user_id are typically not updatable.
version: int # For optimistic locking
version: int
class SettlementPublic(SettlementBase):
id: int
@ -168,13 +147,4 @@ class SettlementPublic(SettlementBase):
updated_at: datetime
version: int
created_by_user_id: int
# payer: Optional[UserPublic] # If we want to include payer details
# payee: Optional[UserPublic] # If we want to include payee details
# group: Optional[GroupPublic] # If we want to include group details
model_config = ConfigDict(from_attributes=True)
# Placeholder for nested schemas (e.g., UserPublic) if needed
# from app.schemas.user import UserPublic
# from app.schemas.list import ListPublic
# from app.schemas.group import GroupPublic
# from app.schemas.item import ItemPublic
model_config = ConfigDict(from_attributes=True)

View File

@ -1,22 +1,17 @@
# app/schemas/group.py
from pydantic import BaseModel, ConfigDict, computed_field
from datetime import datetime, date
from typing import Optional, List
from typing import Optional, List
from .user import UserPublic
from .chore import ChoreHistoryPublic
from .user import UserPublic # Import UserPublic to represent members
from .chore import ChoreHistoryPublic # Import for history
# Properties to receive via API on creation
class GroupCreate(BaseModel):
name: str
# New schema for generating a schedule
class GroupScheduleGenerateRequest(BaseModel):
start_date: date
end_date: date
member_ids: Optional[List[int]] = None # Optional: if not provided, use all members
member_ids: Optional[List[int]] = None
# Properties to return to client
class GroupPublic(BaseModel):
id: int
name: str
@ -34,7 +29,6 @@ class GroupPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
# Properties for UserGroup association
class UserGroupPublic(BaseModel):
id: int
user_id: int
@ -45,9 +39,4 @@ class UserGroupPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
# Properties stored in DB (if needed, often GroupPublic is sufficient)
# class GroupInDB(GroupPublic):
# pass
# We need to rebuild GroupPublic to resolve the forward reference to UserGroupPublic
GroupPublic.model_rebuild()

View File

@ -1,4 +1,4 @@
# app/schemas/health.py
from pydantic import BaseModel
from app.config import settings
@ -6,5 +6,5 @@ class HealthStatus(BaseModel):
"""
Response model for the health check endpoint.
"""
status: str = settings.HEALTH_STATUS_OK # Use configured default value
status: str = settings.HEALTH_STATUS_OK
database: str

View File

@ -1,12 +1,9 @@
# app/schemas/invite.py
from pydantic import BaseModel
from datetime import datetime
# Properties to receive when accepting an invite
class InviteAccept(BaseModel):
code: str
# Properties to return when an invite is created
class InviteCodePublic(BaseModel):
code: str
expires_at: datetime

View File

@ -1,10 +1,8 @@
# app/schemas/item.py
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional
from decimal import Decimal
# Properties to return to client
class ItemPublic(BaseModel):
id: int
list_id: int
@ -19,19 +17,14 @@ class ItemPublic(BaseModel):
version: int
model_config = ConfigDict(from_attributes=True)
# Properties to receive via API on creation
class ItemCreate(BaseModel):
name: str
quantity: Optional[str] = None
# list_id will be from path param
# added_by_id will be from current_user
# Properties to receive via API on update
class ItemUpdate(BaseModel):
name: Optional[str] = None
quantity: Optional[str] = None
is_complete: Optional[bool] = None
price: Optional[Decimal] = None # Price added here for update
position: Optional[int] = None # For reordering
version: int
# completed_by_id will be set internally if is_complete is true
price: Optional[Decimal] = None
position: Optional[int] = None
version: int

View File

@ -1,25 +1,20 @@
# app/schemas/list.py
from pydantic import BaseModel, ConfigDict
from datetime import datetime
from typing import Optional, List
from .item import ItemPublic # Import item schema for nesting
from .item import ItemPublic
# Properties to receive via API on creation
class ListCreate(BaseModel):
name: str
description: Optional[str] = None
group_id: Optional[int] = None # Optional for sharing
group_id: Optional[int] = None
# Properties to receive via API on update
class ListUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
is_complete: Optional[bool] = None
version: int # Client must provide the version for updates
# Potentially add group_id update later if needed
version: int
# Base properties returned by API (common fields)
class ListBase(BaseModel):
id: int
name: str
@ -29,17 +24,15 @@ class ListBase(BaseModel):
is_complete: bool
created_at: datetime
updated_at: datetime
version: int # Include version in responses
version: int
model_config = ConfigDict(from_attributes=True)
# Properties returned when listing lists (no items)
class ListPublic(ListBase):
pass # Inherits all from ListBase
pass
# Properties returned for a single list detail (includes items)
class ListDetail(ListBase):
items: List[ItemPublic] = [] # Include list of items
items: List[ItemPublic] = []
class ListStatus(BaseModel):
updated_at: datetime

View File

@ -1,4 +1,3 @@
# app/schemas/message.py
from pydantic import BaseModel
class Message(BaseModel):

View File

@ -1,6 +1,5 @@
# app/schemas/ocr.py
from pydantic import BaseModel
from typing import List
class OcrExtractResponse(BaseModel):
extracted_items: List[str] # A list of potential item names
extracted_items: List[str]

View File

@ -3,7 +3,7 @@ from typing import Optional, List
from decimal import Decimal
from datetime import datetime
from app.schemas.user import UserPublic # Assuming UserPublic is defined here
from app.schemas.user import UserPublic
class SettlementActivityBase(BaseModel):
expense_split_id: int
@ -21,23 +21,13 @@ class SettlementActivityCreate(SettlementActivityBase):
class SettlementActivityPublic(SettlementActivityBase):
id: int
created_by_user_id: int # User who recorded this activity
created_by_user_id: int
created_at: datetime
updated_at: datetime
payer: Optional[UserPublic] = None # User who made this part of the payment
creator: Optional[UserPublic] = None # User who recorded this activity
payer: Optional[UserPublic] = None
creator: Optional[UserPublic] = None
model_config = ConfigDict(from_attributes=True)
# Schema for updating a settlement activity (if needed in the future)
# class SettlementActivityUpdate(BaseModel):
# amount_paid: Optional[Decimal] = None
# paid_at: Optional[datetime] = None
# @field_validator('amount_paid')
# @classmethod
# def amount_must_be_positive_if_provided(cls, v: Optional[Decimal]) -> Optional[Decimal]:
# if v is not None and v <= Decimal("0"):
# raise ValueError("Amount paid must be a positive value.")
# return v

View File

@ -1,14 +1,11 @@
# app/schemas/user.py
from pydantic import BaseModel, EmailStr, ConfigDict
from datetime import datetime
from typing import Optional
# Shared properties
class UserBase(BaseModel):
email: EmailStr
name: Optional[str] = None
# Properties to receive via API on creation
class UserCreate(UserBase):
password: str
@ -22,26 +19,22 @@ class UserCreate(UserBase):
"is_verified": False
}
# Properties to receive via API on update
class UserUpdate(UserBase):
password: Optional[str] = None
is_active: Optional[bool] = None
is_superuser: Optional[bool] = None
is_verified: Optional[bool] = None
# Properties stored in DB
class UserInDBBase(UserBase):
id: int
password_hash: str
created_at: datetime
model_config = ConfigDict(from_attributes=True) # Use orm_mode in Pydantic v1
model_config = ConfigDict(from_attributes=True)
# Additional properties to return via API (excluding password)
class UserPublic(UserBase):
id: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
# Full user model including hashed password (for internal use/reading from DB)
class User(UserInDBBase):
pass

View File

@ -2,7 +2,7 @@
export const API_VERSION = 'v1'
// API Base URL
export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'https://mitlistbe.mohamad.dev'
export const API_BASE_URL = (window as any).ENV?.VITE_API_URL || 'http://localhost:8000'
// API Endpoints
export const API_ENDPOINTS = {
@ -33,6 +33,7 @@ export const API_ENDPOINTS = {
BASE: '/lists',
BY_ID: (id: string) => `/lists/${id}`,
STATUS: (id: string) => `/lists/${id}/status`,
STATUSES: '/lists/statuses',
ITEMS: (listId: string) => `/lists/${listId}/items`,
ITEM: (listId: string, itemId: string) => `/lists/${listId}/items/${itemId}`,
EXPENSES: (listId: string) => `/lists/${listId}/expenses`,

View File

@ -7,7 +7,6 @@
</template>
<script setup lang="ts">
// No specific logic for AuthLayout
</script>
<style lang="scss" scoped>
@ -15,13 +14,12 @@
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: var(--bg-color-page, #f0f2f5);
}
.auth-page-container {
width: 100%;
max-width: 450px; // Max width for login/signup forms
max-width: 450px;
padding: 2rem;
}
</style>

View File

@ -4,9 +4,7 @@
<header class="app-header">
<div class="toolbar-title">mitlist</div>
<!-- Group all authenticated controls for cleaner conditional rendering -->
<div v-if="authStore.isAuthenticated" class="header-controls">
<!-- Add Menu -->
<div class="control-item">
<button ref="addMenuTrigger" class="icon-button" :class="{ 'is-active': addMenuOpen }"
:aria-expanded="addMenuOpen" aria-controls="add-menu-dropdown" aria-label="Add new list or group"
@ -23,7 +21,6 @@
</Transition>
</div>
<!-- Language Menu -->
<div class="control-item">
<button ref="languageMenuTrigger" class="icon-button language-button"
:class="{ 'is-active': languageMenuOpen }" :aria-expanded="languageMenuOpen"
@ -44,12 +41,10 @@
</Transition>
</div>
<!-- User Menu -->
<div class="control-item">
<button ref="userMenuTrigger" class="icon-button user-menu-button" :class="{ 'is-active': userMenuOpen }"
:aria-expanded="userMenuOpen" aria-controls="user-menu-dropdown" aria-label="User menu"
@click="toggleUserMenu">
<!-- Show user avatar if available, otherwise a fallback icon -->
<img v-if="authStore.user?.avatarUrl" :src="authStore.user.avatarUrl" alt="User Avatar"
class="user-avatar" />
<span v-else class="material-icons">account_circle</span>
@ -67,9 +62,7 @@
</div>
</header>
<!-- =================================================================
MAIN CONTENT
================================================================== -->
<main class="page-container">
<router-view v-slot="{ Component, route }">
<keep-alive v-if="route.meta.keepAlive">
@ -81,32 +74,24 @@
<OfflineIndicator />
<!-- =================================================================
FOOTER NAVIGATION
Improved with more semantic router-links and better active state styling.
================================================================== -->
<footer class="app-footer">
<nav class="tabs">
<router-link to="/lists" class="tab-item" active-class="active">
<span class="material-icons">list</span>
<span class="tab-text">Lists</span>
</router-link>
<!-- Use a RouterLink for semantics and a11y, but keep custom click handler -->
<router-link to="/groups" class="tab-item" :class="{ 'active': $route.path.startsWith('/groups') }"
@click.prevent="navigateToGroups">
<span class="material-icons">group</span>
<span class="tab-text">Groups</span>
</router-link>
<router-link to="/chores" class="tab-item" active-class="active">
<span class="material-icons">task_alt</span> <!-- More appropriate icon for chores -->
<span class="material-icons">task_alt</span>
<span class="tab-text">Chores</span>
</router-link>
</nav>
</footer>
<!-- =================================================================
MODALS
================================================================== -->
<CreateListModal v-model="showCreateListModal" @created="handleListCreated" />
<CreateGroupModal v-model="showCreateGroupModal" @created="handleGroupCreated" />
</div>
@ -124,7 +109,6 @@ import CreateListModal from '@/components/CreateListModal.vue';
import CreateGroupModal from '@/components/CreateGroupModal.vue';
import { onClickOutside } from '@vueuse/core';
// Store and Router setup
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
@ -132,30 +116,24 @@ const notificationStore = useNotificationStore();
const groupStore = useGroupStore();
const { t, locale } = useI18n();
// --- Dropdown Logic (Re-integrated from composable) ---
// 1. Add Menu Dropdown
const addMenuOpen = ref(false);
const addMenuDropdown = ref<HTMLElement | null>(null);
const addMenuTrigger = ref<HTMLElement | null>(null);
const toggleAddMenu = () => { addMenuOpen.value = !addMenuOpen.value; };
onClickOutside(addMenuDropdown, () => { addMenuOpen.value = false; }, { ignore: [addMenuTrigger] });
// 2. Language Menu Dropdown
const languageMenuOpen = ref(false);
const languageMenuDropdown = ref<HTMLElement | null>(null);
const languageMenuTrigger = ref<HTMLElement | null>(null);
const toggleLanguageMenu = () => { languageMenuOpen.value = !languageMenuOpen.value; };
onClickOutside(languageMenuDropdown, () => { languageMenuOpen.value = false; }, { ignore: [languageMenuTrigger] });
// 3. User Menu Dropdown
const userMenuOpen = ref(false);
const userMenuDropdown = ref<HTMLElement | null>(null);
const userMenuTrigger = ref<HTMLElement | null>(null);
const toggleUserMenu = () => { userMenuOpen.value = !userMenuOpen.value; };
onClickOutside(userMenuDropdown, () => { userMenuOpen.value = false; }, { ignore: [userMenuTrigger] });
// --- Language Selector Logic ---
const availableLanguages = computed(() => ({
en: t('languageSelector.languages.en'),
de: t('languageSelector.languages.de'),
@ -168,24 +146,23 @@ const currentLanguageCode = computed(() => locale.value);
const changeLanguage = (languageCode: string) => {
locale.value = languageCode;
localStorage.setItem('language', languageCode);
languageMenuOpen.value = false; // Close menu on selection
languageMenuOpen.value = false;
notificationStore.addNotification({
type: 'success',
message: `Language changed to ${availableLanguages.value[languageCode as keyof typeof availableLanguages.value]}`,
});
};
// --- Modal Handling ---
const showCreateListModal = ref(false);
const showCreateGroupModal = ref(false);
const handleAddList = () => {
addMenuOpen.value = false; // Close menu
addMenuOpen.value = false;
showCreateListModal.value = true;
};
const handleAddGroup = () => {
addMenuOpen.value = false; // Close menu
addMenuOpen.value = false;
showCreateGroupModal.value = true;
};
@ -197,13 +174,12 @@ const handleListCreated = (newList: any) => {
const handleGroupCreated = (newGroup: any) => {
notificationStore.addNotification({ message: `Group '${newGroup.name}' created successfully`, type: 'success' });
showCreateGroupModal.value = false;
groupStore.fetchGroups(); // Refresh groups after creation
groupStore.fetchGroups();
};
// --- User and Navigation Logic ---
const handleLogout = async () => {
try {
userMenuOpen.value = false; // Close menu
userMenuOpen.value = false;
authStore.logout();
notificationStore.addNotification({ type: 'success', message: 'Logged out successfully' });
await router.push('/auth/login');
@ -224,9 +200,7 @@ const navigateToGroups = () => {
}
};
// --- App Initialization ---
onMounted(async () => {
// Fetch essential data for authenticated users
if (authStore.isAuthenticated) {
try {
await authStore.fetchCurrentUser();
@ -236,7 +210,6 @@ onMounted(async () => {
}
}
// Load saved language preference
const savedLanguage = localStorage.getItem('language');
if (savedLanguage && Object.keys(availableLanguages.value).includes(savedLanguage)) {
locale.value = savedLanguage;
@ -245,14 +218,13 @@ onMounted(async () => {
</script>
<style lang="scss" scoped>
// Using Google's outlined icons for a lighter feel
@import url('https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined');
.main-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
background-color: #f9f9f9; // A slightly off-white background for the main page
background-color: #f9f9f9;
}
.app-header {
@ -287,7 +259,7 @@ onMounted(async () => {
.icon-button {
background: none;
border: 1px solid transparent; // Prevents layout shift on hover
border: 1px solid transparent;
color: var(--primary);
cursor: pointer;
padding: 0.5rem;
@ -325,7 +297,7 @@ onMounted(async () => {
}
.user-menu-button {
padding: 0; // Remove padding if image is used
padding: 0;
width: 40px;
height: 40px;
overflow: hidden;
@ -340,14 +312,14 @@ onMounted(async () => {
.dropdown-menu {
position: absolute;
right: 0;
top: calc(100% + 8px); // A bit more space
top: calc(100% + 8px);
background-color: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
min-width: 180px;
z-index: 101;
overflow: hidden; // To respect child border-radius
overflow: hidden;
a {
display: block;
@ -395,7 +367,6 @@ onMounted(async () => {
font-weight: 500;
}
/* Dropdown transition */
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
@ -435,7 +406,7 @@ onMounted(async () => {
flex-direction: column;
align-items: center;
justify-content: center;
color: #757575; // Softer default color
color: #757575;
text-decoration: none;
padding: 0.5rem 0;
gap: 4px;

View File

@ -523,7 +523,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
</div>
<div v-if="choreForm.frequency === 'custom'" class="form-group">
<label class="form-label" for="chore-interval">{{ t('choresPage.form.interval', 'Interval (days)')
}}</label>
}}</label>
<input id="chore-interval" type="number" v-model.number="choreForm.custom_interval_days"
class="form-input" :placeholder="t('choresPage.form.intervalPlaceholder')" min="1">
</div>
@ -544,7 +544,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
</div>
<div v-if="choreForm.type === 'group'" class="form-group">
<label class="form-label" for="chore-group">{{ t('choresPage.form.assignGroup', 'Assign to Group')
}}</label>
}}</label>
<select id="chore-group" v-model="choreForm.group_id" class="form-input">
<option v-for="group in groups" :key="group.id" :value="group.id">{{ group.name }}</option>
</select>
@ -553,7 +553,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<div class="modal-footer">
<button type="button" class="btn btn-neutral" @click="showChoreModal = false">{{
t('choresPage.form.cancel', 'Cancel')
}}</button>
}}</button>
<button type="submit" class="btn btn-primary">{{ isEditing ? t('choresPage.form.save', 'Save Changes') :
t('choresPage.form.create', 'Create') }}</button>
</div>
@ -578,7 +578,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
t('choresPage.deleteConfirm.cancel', 'Cancel') }}</button>
<button type="button" class="btn btn-danger" @click="deleteChore">{{
t('choresPage.deleteConfirm.delete', 'Delete')
}}</button>
}}</button>
</div>
</div>
</div>
@ -603,7 +603,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<div class="detail-item">
<span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span>
}}</span>
</div>
<div class="detail-item">
<span class="label">Due date:</span>
@ -635,7 +635,7 @@ const getDueDateStatus = (chore: ChoreWithCompletion) => {
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-item">
<div class="assignment-main">
<span class="assigned-user">{{ assignment.assigned_user?.name || assignment.assigned_user?.email
}}</span>
}}</span>
<span class="assignment-status" :class="{ completed: assignment.is_complete }">
{{ assignment.is_complete ? '✅ Completed' : '⏳ Pending' }}
</span>

View File

@ -12,7 +12,6 @@
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
// No script logic needed for this simple page
</script>
<style scoped>
@ -20,16 +19,16 @@ const { t } = useI18n();
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh; /* Fallback for browsers that don't support dvh */
min-height: 100vh;
min-height: 100dvh;
background-color: var(--secondary-accent); /* Light Blue */
background-color: var(--secondary-accent);
color: var(--dark);
padding: 2rem;
font-family: "Patrick Hand", cursive;
}
.error-code {
font-size: clamp(15vh, 25vw, 30vh); /* Responsive font size */
font-size: clamp(15vh, 25vw, 30vh);
font-weight: bold;
color: var(--primary);
line-height: 1;
@ -39,14 +38,16 @@ const { t } = useI18n();
.error-message {
font-size: clamp(1.5rem, 4vw, 2.5rem);
opacity: 0.8;
margin-top: -1rem; /* Adjust based on font size */
margin-top: -1rem;
margin-bottom: 2rem;
}
.btn-primary {
/* Ensure primary button styles are applied if not already by global .btn */
background-color: var(--primary);
color: var(--dark);
}
.mt-3 { margin-top: 1.5rem; }
.mt-3 {
margin-top: 1.5rem;
}
</style>

View File

@ -42,7 +42,6 @@
+
</button>
<!-- Invite Members Popup -->
<div v-show="showInviteUI" ref="inviteUIRef" class="invite-popup">
<div class="popup-header">
<VHeading :level="3" class="!m-0 !p-0 !border-none">{{ t('groupDetailPage.invites.title') }}
@ -72,14 +71,12 @@
</div>
<div class="neo-section-cntainer">
<!-- Lists Section -->
<div class="neo-section">
<ChoresPage :group-id="groupId" />
<ListsPage :group-id="groupId" />
</div>
<!-- Expenses Section -->
<div class="mt-4 neo-section">
<div class="flex justify-between items-center w-full mb-2">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.expenses.title') }}</VHeading>
@ -181,7 +178,6 @@
<!-- Group Activity Log Section -->
<div class="mt-4 neo-section">
<VHeading :level="3" class="neo-section-header">{{ t('groupDetailPage.activityLog.title') }}</VHeading>
<div v-if="groupHistoryLoading" class="text-center">
@ -199,7 +195,6 @@
<VAlert v-else type="info" :message="t('groupDetailPage.groupNotFound')" />
<!-- Settle Share Modal -->
<VModal v-model="showSettleModal" :title="t('groupDetailPage.settleShareModal.title')"
@update:modelValue="!$event && closeSettleShareModal()" size="md">
<template #default>
@ -224,18 +219,16 @@
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{
t('groupDetailPage.settleShareModal.cancelButton')
}}</VButton>
}}</VButton>
<VButton variant="primary" @click="handleConfirmSettle" :disabled="isSettlementLoading">{{
t('groupDetailPage.settleShareModal.confirmButton')
}}</VButton>
}}</VButton>
</template>
</VModal>
<!-- Enhanced Chore Detail Modal -->
<VModal v-model="showChoreDetailModal" :title="selectedChore?.name" size="lg">
<template #default>
<div v-if="selectedChore" class="chore-detail-content">
<!-- Chore Overview -->
<div class="chore-overview-section">
<div class="chore-status-summary">
<div class="status-badges">
@ -249,7 +242,7 @@
<div class="meta-item">
<span class="label">Created by:</span>
<span class="value">{{ selectedChore.creator?.name || selectedChore.creator?.email || 'Unknown'
}}</span>
}}</span>
</div>
<div class="meta-item">
<span class="label">Created:</span>
@ -274,7 +267,6 @@
</div>
</div>
<!-- Current Assignments -->
<div class="assignments-section">
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.assignmentsTitle') }}</VHeading>
<div v-if="loadingAssignments" class="loading-assignments">
@ -284,7 +276,6 @@
<div v-else-if="selectedChoreAssignments.length > 0" class="assignments-list">
<div v-for="assignment in selectedChoreAssignments" :key="assignment.id" class="assignment-card">
<template v-if="editingAssignment?.id === assignment.id">
<!-- Inline Editing UI -->
<div class="editing-assignment">
<VFormField label="Assigned to:">
<VSelect v-if="group?.members"
@ -339,7 +330,6 @@
<p v-else class="no-assignments">{{ t('groupDetailPage.choreDetailModal.noAssignments') }}</p>
</div>
<!-- Assignment History -->
<div
v-if="selectedChore.assignments && selectedChore.assignments.some(a => a.history && a.history.length > 0)"
class="assignment-history-section">
@ -358,7 +348,6 @@
</div>
</div>
<!-- Chore History -->
<div class="chore-history-section">
<VHeading :level="4">{{ t('groupDetailPage.choreDetailModal.choreHistoryTitle') }}</VHeading>
<div v-if="selectedChore.history && selectedChore.history.length > 0" class="history-timeline">
@ -374,7 +363,6 @@
</template>
</VModal>
<!-- Generate Schedule Modal -->
<VModal v-model="showGenerateScheduleModal" :title="t('groupDetailPage.generateScheduleModal.title')">
<VFormField :label="t('groupDetailPage.generateScheduleModal.startDateLabel')">
<VInput type="date" v-model="scheduleForm.start_date" />
@ -382,7 +370,6 @@
<VFormField :label="t('groupDetailPage.generateScheduleModal.endDateLabel')">
<VInput type="date" v-model="scheduleForm.end_date" />
</VFormField>
<!-- Member selection can be added here if desired -->
<template #footer>
<VButton @click="showGenerateScheduleModal = false" variant="neutral">{{ t('shared.cancel') }}</VButton>
<VButton @click="handleGenerateSchedule" :disabled="generatingSchedule">{{
@ -396,10 +383,9 @@
<script setup lang="ts">
import { ref, onMounted, computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
// import { useRoute } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Assuming path
import { apiClient, API_ENDPOINTS } from '@/config/api';
import { useClipboard, useStorage } from '@vueuse/core';
import ListsPage from './ListsPage.vue'; // Import ListsPage
import ListsPage from './ListsPage.vue';
import { useNotificationStore } from '@/stores/notifications';
import { choreService } from '../services/choreService'
import type { Chore, ChoreFrequency, ChoreAssignment, ChoreHistory, ChoreAssignmentHistory } from '../types/chore'
@ -423,13 +409,12 @@ import VIcon from '@/components/valerie/VIcon.vue';
import VModal from '@/components/valerie/VModal.vue';
import VSelect from '@/components/valerie/VSelect.vue';
import { onClickOutside } from '@vueuse/core'
import { groupService } from '../services/groupService'; // New service
import { groupService } from '../services/groupService';
import ChoresPage from './ChoresPage.vue';
const { t } = useI18n();
// Caching setup
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const CACHE_DURATION = 5 * 60 * 1000;
interface CachedGroup { group: Group; timestamp: number; }
const cachedGroups = useStorage<Record<string, CachedGroup>>('cached-groups-v1', {});
@ -437,9 +422,6 @@ const cachedGroups = useStorage<Record<string, CachedGroup>>('cached-groups-v1',
interface CachedChores { chores: Chore[]; timestamp: number; }
const cachedUpcomingChores = useStorage<Record<string, CachedChores>>('cached-group-chores-v1', {});
// interface CachedExpenses { expenses: Expense[]; timestamp: number; }
// const cachedRecentExpenses = useStorage<Record<string, CachedExpenses>>('cached-group-expenses-v1', {});
interface Group {
id: string | number;
name: string;
@ -456,9 +438,6 @@ const props = defineProps<{
id: string;
}>();
// const route = useRoute();
// const $q = useQuasar(); // Not used anymore
const notificationStore = useNotificationStore();
const group = ref<Group | null>(null);
const loading = ref(true);
@ -484,29 +463,24 @@ onClickOutside(inviteUIRef, () => {
showInviteUI.value = false
}, { ignore: [addMemberButtonRef] })
// groupId is directly from props.id now, which comes from the route path param
const groupId = computed(() => props.id);
const { copy, copied, isSupported: clipboardIsSupported } = useClipboard({
source: computed(() => inviteCode.value || '')
});
// Chores state
const upcomingChores = ref<Chore[]>([])
// Add new state for expenses
const recentExpenses = ref<Expense[]>([])
const expandedExpenses = ref<Set<number>>(new Set());
const authStore = useAuthStore();
// Settle Share Modal State
const showSettleModal = ref(false);
const selectedSplitForSettlement = ref<ExpenseSplit | null>(null);
const settleAmount = ref<string>('');
const settleAmountError = ref<string | null>(null);
const isSettlementLoading = ref(false);
// New State
const showChoreDetailModal = ref(false);
const selectedChore = ref<Chore | null>(null);
const editingAssignment = ref<Partial<ChoreAssignment> | null>(null);
@ -554,25 +528,22 @@ const getApiErrorMessage = (err: unknown, fallbackMessageKey: string): string =>
const fetchActiveInviteCode = async () => {
if (!groupId.value) return;
// Consider adding a loading state for this fetch if needed, e.g., initialInviteCodeLoading
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.GET_ACTIVE_INVITE(String(groupId.value)));
if (response.data && response.data.code) {
inviteCode.value = response.data.code;
inviteExpiresAt.value = response.data.expires_at; // Store expiry
inviteExpiresAt.value = response.data.expires_at;
} else {
inviteCode.value = null; // No active code found
inviteCode.value = null;
inviteExpiresAt.value = null;
}
} catch (err: any) {
if (err.response && err.response.status === 404) {
inviteCode.value = null; // Explicitly set to null on 404
inviteCode.value = null;
inviteExpiresAt.value = null;
// Optional: notify user or set a flag to show "generate one" message more prominently
console.info(t('groupDetailPage.console.noActiveInvite'));
} else {
const message = err instanceof Error ? err.message : t('groupDetailPage.errors.failedToFetchActiveInvite');
// error.value = message; // This would display a large error banner, might be too much
console.error('Error fetching active invite code:', err);
notificationStore.addNotification({ message, type: 'error' });
}
@ -584,41 +555,33 @@ const fetchGroupDetails = async () => {
const groupIdStr = String(groupId.value);
const cached = cachedGroups.value[groupIdStr];
// If we have any cached data (even stale), show it first to avoid loading spinner.
if (cached) {
group.value = cached.group;
loading.value = false;
} else {
// Only show loading spinner if there is no cached data at all.
loading.value = true;
}
// Reset error state for the new fetch attempt
error.value = null;
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BY_ID(groupIdStr));
group.value = response.data;
// Update cache on successful fetch
cachedGroups.value[groupIdStr] = {
group: response.data,
timestamp: Date.now(),
};
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('groupDetailPage.errors.failedToFetchGroupDetails');
// Only show the main error banner if we have no data at all to show
if (!group.value) {
error.value = message;
}
console.error('Error fetching group details:', err);
// Always show a notification for failures, even background ones
notificationStore.addNotification({ message, type: 'error' });
} finally {
// If we were showing the loader, hide it.
if (loading.value) {
loading.value = false;
}
}
// Fetch active invite code after group details are loaded or retrieved from cache
await fetchActiveInviteCode();
};
@ -630,10 +593,9 @@ const generateInviteCode = async () => {
const response = await apiClient.post(API_ENDPOINTS.GROUPS.CREATE_INVITE(String(groupId.value)));
if (response.data && response.data.code) {
inviteCode.value = response.data.code;
inviteExpiresAt.value = response.data.expires_at; // Update with new expiry
inviteExpiresAt.value = response.data.expires_at;
notificationStore.addNotification({ message: t('groupDetailPage.notifications.generateInviteSuccess'), type: 'success' });
} else {
// Should not happen if POST is successful and returns the code
throw new Error(t('groupDetailPage.invites.errors.newDataInvalid'));
}
} catch (err: unknown) {
@ -654,16 +616,12 @@ const copyInviteCodeHandler = async () => {
if (copied.value) {
copySuccess.value = true;
setTimeout(() => (copySuccess.value = false), 2000);
// Optionally, notify success via store if preferred over inline message
// notificationStore.addNotification({ message: 'Invite code copied!', type: 'info' });
} else {
notificationStore.addNotification({ message: t('groupDetailPage.notifications.copyInviteFailed'), type: 'error' });
}
};
const canRemoveMember = (member: GroupMember): boolean => {
// Simplification: For now, assume a user with role 'owner' can remove anyone but another owner.
// A real implementation would check the current user's ID against the member to prevent self-removal.
const isOwner = group.value?.members?.find(m => m.id === member.id)?.role === 'owner';
return !isOwner;
};
@ -674,7 +632,6 @@ const removeMember = async (memberId: number) => {
removingMember.value = memberId;
try {
await apiClient.delete(API_ENDPOINTS.GROUPS.MEMBER(String(groupId.value), String(memberId)));
// Refresh group details to update the members list
await fetchGroupDetails();
notificationStore.addNotification({
message: t('groupDetailPage.notifications.removeMemberSuccess'),
@ -689,7 +646,6 @@ const removeMember = async (memberId: number) => {
}
};
// Chores methods
const loadUpcomingChores = async () => {
if (!groupId.value) return
const groupIdStr = String(groupId.value);
@ -744,7 +700,7 @@ const getChoreStatusInfo = (chore: Chore) => {
const formatFrequency = (frequency: ChoreFrequency) => {
const options: Record<ChoreFrequency, string> = {
one_time: t('choresPage.frequencyOptions.oneTime'), // Reusing existing keys
one_time: t('choresPage.frequencyOptions.oneTime'),
daily: t('choresPage.frequencyOptions.daily'),
weekly: t('choresPage.frequencyOptions.weekly'),
monthly: t('choresPage.frequencyOptions.monthly'),
@ -758,13 +714,12 @@ const getFrequencyBadgeVariant = (frequency: ChoreFrequency): BadgeVariant => {
one_time: 'neutral',
daily: 'info',
weekly: 'success',
monthly: 'accent', // Using accent for purple as an example
monthly: 'accent',
custom: 'warning'
};
return colorMap[frequency] || 'secondary';
};
// Add new methods for expenses
const loadRecentExpenses = async () => {
if (!groupId.value) return
try {
@ -783,12 +738,7 @@ const formatAmount = (amount: string) => {
}
const formatSplitType = (type: string) => {
// Assuming 'type' is like 'exact_amounts' or 'item_based'
const key = `groupDetailPage.expenses.splitTypes.${type.toLowerCase().replace(/_([a-z])/g, g => g[1].toUpperCase())}`;
// This creates keys like 'groupDetailPage.expenses.splitTypes.exactAmounts'
// Check if translation exists, otherwise fallback to a simple formatted string
// For simplicity in this subtask, we'll assume keys will be added.
// A more robust solution would check i18n.global.te(key) or have a fallback.
return t(key);
};
@ -796,9 +746,9 @@ const getSplitTypeBadgeVariant = (type: string): BadgeVariant => {
const colorMap: Record<string, BadgeVariant> = {
equal: 'info',
exact_amounts: 'success',
percentage: 'accent', // Using accent for purple
percentage: 'accent',
shares: 'warning',
item_based: 'secondary', // Using secondary for teal as an example
item_based: 'secondary',
};
return colorMap[type] || 'neutral';
};
@ -942,7 +892,6 @@ const toggleMemberMenu = (memberId: number) => {
activeMemberMenu.value = null;
} else {
activeMemberMenu.value = memberId;
// Close invite UI if it's open
showInviteUI.value = false;
}
};
@ -950,7 +899,7 @@ const toggleMemberMenu = (memberId: number) => {
const toggleInviteUI = () => {
showInviteUI.value = !showInviteUI.value;
if (showInviteUI.value) {
activeMemberMenu.value = null; // Close any open member menu
activeMemberMenu.value = null;
}
};
@ -958,7 +907,6 @@ const openChoreDetailModal = async (chore: Chore) => {
selectedChore.value = chore;
showChoreDetailModal.value = true;
// Load assignments for this chore
loadingAssignments.value = true;
try {
selectedChoreAssignments.value = await choreService.getChoreAssignments(chore.id);
@ -972,7 +920,6 @@ const openChoreDetailModal = async (chore: Chore) => {
loadingAssignments.value = false;
}
// Optionally lazy load history if not already loaded with the chore
if (!chore.history || chore.history.length === 0) {
try {
const history = await choreService.getChoreHistory(chore.id);
@ -1008,8 +955,7 @@ const saveAssignmentEdit = async (assignmentId: number) => {
due_date: editingAssignment.value.due_date,
assigned_to_user_id: editingAssignment.value.assigned_to_user_id
});
// Update local state
loadUpcomingChores(); // Re-fetch all chores to get updates
loadUpcomingChores();
cancelAssignmentEdit();
notificationStore.addNotification({ message: 'Assignment updated', type: 'success' });
} catch (error) {
@ -1023,7 +969,7 @@ const handleGenerateSchedule = async () => {
await groupService.generateSchedule(String(groupId.value), scheduleForm);
notificationStore.addNotification({ message: 'Schedule generated successfully', type: 'success' });
showGenerateScheduleModal.value = false;
loadUpcomingChores(); // Refresh the chore list
loadUpcomingChores();
} catch (error) {
notificationStore.addNotification({ message: 'Failed to generate schedule', type: 'error' });
} finally {

View File

@ -1,14 +1,10 @@
<template>
<main class="container page-padding">
<!-- <h1 class="mb-3">Your Groups</h1> -->
<!-- Initial Loading Spinner -->
<div v-if="isInitiallyLoading && groups.length === 0 && !fetchError" class="text-center my-5">
<p>{{ t('groupsPage.loadingText', 'Loading groups...') }}</p>
<span class="spinner-dots-lg" role="status"><span /><span /><span /></span>
</div>
<!-- Error Display -->
<div v-else-if="fetchError" class="alert alert-error mb-3" role="alert">
<div class="alert-content">
<svg class="icon" aria-hidden="true">
@ -20,7 +16,6 @@
t('groupsPage.retryButton') }}</button>
</div>
<!-- Empty State: show if not initially loading, no error, and groups genuinely empty -->
<div v-else-if="!isInitiallyLoading && groups.length === 0" class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
@ -35,7 +30,6 @@
</button>
</div>
<!-- Groups List -->
<div v-else-if="groups.length > 0" class="mb-3">
<div class="neo-groups-grid">
<div v-for="group in groups" :key="group.id" class="neo-group-card" @click="selectGroup(group)">
@ -55,7 +49,6 @@
</div>
</div>
<!-- Create or Join Group Dialog -->
<div v-if="showCreateGroupDialog" class="modal-backdrop open" @click.self="closeCreateGroupDialog">
<div class="modal-container" ref="createGroupModalRef" role="dialog" aria-modal="true"
aria-labelledby="createGroupTitle">
@ -70,7 +63,6 @@
</button>
</div>
<!-- Tabs -->
<div class="modal-tabs">
<button @click="activeTab = 'create'" :class="{ 'active': activeTab === 'create' }">
{{ t('groupsPage.createDialog.createButton') }}
@ -80,7 +72,6 @@
</button>
</div>
<!-- Create Form -->
<form v-if="activeTab === 'create'" @submit.prevent="handleCreateGroup">
<div class="modal-body">
<div class="form-group">
@ -101,7 +92,6 @@
</div>
</form>
<!-- Join Form -->
<form v-if="activeTab === 'join'" @submit.prevent="handleJoinGroup">
<div class="modal-body">
<div class="form-group">
@ -124,7 +114,6 @@
</div>
</div>
<!-- Create List Modal -->
<CreateListModal v-model="showCreateListModal" :groups="availableGroupsForModal" @created="onListCreated" />
</main>
</template>
@ -156,7 +145,7 @@ const router = useRouter();
const notificationStore = useNotificationStore();
const groups = ref<Group[]>([]);
const fetchError = ref<string | null>(null);
const isInitiallyLoading = ref(true); // Added for managing initial load state
const isInitiallyLoading = ref(true);
const showCreateGroupDialog = ref(false);
const newGroupName = ref('');
@ -175,56 +164,44 @@ const joinGroupFormError = ref<string | null>(null);
const showCreateListModal = ref(false);
const availableGroupsForModal = ref<{ label: string; value: number; }[]>([]);
// Cache groups in localStorage
const cachedGroups = useStorage<Group[]>('cached-groups', []);
const cachedTimestamp = useStorage<number>('cached-groups-timestamp', 0);
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
const CACHE_DURATION = 5 * 60 * 1000;
// Attempt to initialize groups from valid cache
const now = Date.now();
if (cachedGroups.value && (now - cachedTimestamp.value) < CACHE_DURATION) {
if (cachedGroups.value.length > 0) {
groups.value = JSON.parse(JSON.stringify(cachedGroups.value)); // Deep copy for safety from potential proxy issues
groups.value = JSON.parse(JSON.stringify(cachedGroups.value));
isInitiallyLoading.value = false;
} else {
groups.value = [];
isInitiallyLoading.value = false;
} else { // Valid cache, but it's empty
groups.value = []; // Ensure it's an empty array
isInitiallyLoading.value = false; // We know it's empty, not "loading"
}
}
// If cache is stale or not present, groups.value remains [], and isInitiallyLoading remains true.
// Fetch fresh data from API
const fetchGroups = async (isRetryAttempt = false) => {
// If it's a retry triggered by user AND the list is currently empty, set loading to true to show spinner.
// Or, if it's the very first load (isInitiallyLoading is still true) AND list is empty (no cache hit).
if ((isRetryAttempt && groups.value.length === 0) || (isInitiallyLoading.value && groups.value.length === 0)) {
isInitiallyLoading.value = true;
}
// If groups.value has items (from cache), isInitiallyLoading is false, and this fetch acts as a background update.
fetchError.value = null; // Clear previous error before new attempt
fetchError.value = null;
try {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
const freshGroups = response.data as Group[];
groups.value = freshGroups;
// Update cache
cachedGroups.value = freshGroups;
cachedTimestamp.value = Date.now();
} catch (err: any) {
let message = t('groupsPage.errors.fetchFailed');
// Attempt to get a more specific error message from the API response
if (err.response && err.response.data && err.response.data.detail) {
message = err.response.data.detail;
} else if (err.message) {
message = err.message;
}
fetchError.value = message;
// If fetch fails, groups.value will retain its current state (either from cache or empty).
// The template will then show the error message.
} finally {
isInitiallyLoading.value = false; // Mark loading as complete for this attempt
isInitiallyLoading.value = false;
}
};
@ -243,7 +220,7 @@ watch(activeTab, (newTab) => {
});
const openCreateGroupDialog = () => {
activeTab.value = 'create'; // Default to create tab
activeTab.value = 'create';
newGroupName.value = '';
createGroupFormError.value = null;
inviteCodeToJoin.value = '';
@ -277,7 +254,6 @@ const handleCreateGroup = async () => {
groups.value.push(newGroup);
closeCreateGroupDialog();
notificationStore.addNotification({ message: t('groupsPage.notifications.groupCreatedSuccess', { groupName: newGroup.name }), type: 'success' });
// Update cache
cachedGroups.value = groups.value;
cachedTimestamp.value = Date.now();
} else {
@ -302,28 +278,19 @@ const handleJoinGroup = async () => {
joinGroupFormError.value = null;
joiningGroup.value = true;
try {
const response = await apiClient.post(API_ENDPOINTS.INVITES.ACCEPT, {
code: inviteCodeToJoin.value.trim()
});
const joinedGroup = response.data as Group; // Adjust based on actual API response for joined group
if (joinedGroup && joinedGroup.id && joinedGroup.name) {
// Check if group already in list to prevent duplicates if API returns the group info
if (!groups.value.find(g => g.id === joinedGroup.id)) {
groups.value.push(joinedGroup);
}
inviteCodeToJoin.value = '';
notificationStore.addNotification({ message: t('groupsPage.notifications.joinSuccessNamed', { groupName: joinedGroup.name }), type: 'success' });
// Update cache
cachedGroups.value = groups.value;
cachedTimestamp.value = Date.now();
closeCreateGroupDialog();
} else {
// If API returns only success message, re-fetch groups
await fetchGroups(); // Refresh the list of groups
inviteCodeToJoin.value = '';
notificationStore.addNotification({ message: t('groupsPage.notifications.joinSuccessGeneric'), type: 'success' });
closeCreateGroupDialog();
const response = await apiClient.post(
API_ENDPOINTS.INVITES.ACCEPT(inviteCodeToJoin.value.trim()),
{ code: inviteCodeToJoin.value.trim() }
);
const joinedGroup = response.data as Group;
if (!groups.value.find(g => g.id === joinedGroup.id)) {
groups.value.push(joinedGroup);
}
inviteCodeToJoin.value = '';
notificationStore.addNotification({ message: t('groupsPage.notifications.joinSuccessNamed', { groupName: joinedGroup.name }), type: 'success' });
cachedGroups.value = groups.value;
cachedTimestamp.value = Date.now();
closeCreateGroupDialog();
} catch (error: any) {
const message = error.response?.data?.detail || (error instanceof Error ? error.message : t('groupsPage.errors.joinFailed'));
joinGroupFormError.value = message;
@ -339,7 +306,6 @@ const selectGroup = (group: Group) => {
};
const openCreateListDialog = (group: Group) => {
// Ensure we have the latest groups data
fetchGroups().then(() => {
availableGroupsForModal.value = [{
label: group.name,
@ -354,14 +320,10 @@ const onListCreated = (newList: any) => {
message: t('groupsPage.notifications.listCreatedSuccess', { listName: newList.name }),
type: 'success'
});
// Optionally refresh the groups list to show the new list
fetchGroups(); // Refresh data, isRetryAttempt will be false
fetchGroups();
};
onMounted(() => {
// groups might have been populated from cache synchronously above.
// isInitiallyLoading reflects whether cache was used or if we need to show a spinner.
// Call fetchGroups to get fresh data or perform initial load if cache was missed.
fetchGroups();
});
</script>
@ -389,7 +351,6 @@ onMounted(() => {
margin-left: 0.5rem;
}
/* Responsive grid for cards */
.neo-groups-grid {
display: flex;
flex-wrap: wrap;
@ -399,7 +360,6 @@ onMounted(() => {
margin-bottom: 2rem;
}
/* Card styles */
.neo-group-card,
.neo-create-group-card {
border-radius: 18px;
@ -429,7 +389,6 @@ onMounted(() => {
.neo-group-header {
font-weight: 900;
font-size: 1.25rem;
/* margin-bottom: 1rem; */
letter-spacing: 0.5px;
text-transform: none;
}
@ -489,7 +448,6 @@ details[open] .expand-icon {
cursor: pointer;
}
/* Modal Tabs */
.modal-tabs {
display: flex;
border-bottom: 1px solid #eee;
@ -515,7 +473,6 @@ details[open] .expand-icon {
font-weight: 600;
}
/* Responsive adjustments */
@media (max-width: 900px) {
.neo-groups-grid {
gap: 1.2rem;

View File

@ -379,10 +379,10 @@
<template #footer>
<VButton variant="neutral" @click="closeSettleShareModal">{{
$t('listDetailPage.modals.settleShare.cancelButton')
}}</VButton>
}}</VButton>
<VButton variant="primary" @click="handleConfirmSettle">{{
$t('listDetailPage.modals.settleShare.confirmButton')
}}</VButton>
}}</VButton>
</template>
</VModal>
@ -696,9 +696,8 @@ const onAddItem = async () => {
}
addingItem.value = true;
// Create optimistic item
const optimisticItem: ItemWithUI = {
id: Date.now(), // Temporary ID
id: Date.now(),
name: itemName,
quantity: typeof newItem.value.quantity === 'string' ? Number(newItem.value.quantity) : (newItem.value.quantity || null),
is_complete: false,
@ -713,10 +712,8 @@ const onAddItem = async () => {
swiped: false
};
// Add item optimistically to the list
list.value.items.push(optimisticItem);
// Clear input immediately for better UX
newItem.value.name = '';
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
@ -760,7 +757,6 @@ const onAddItem = async () => {
);
const addedItem = response.data as Item;
// Replace optimistic item with real item from server
const index = list.value.items.findIndex(i => i.id === optimisticItem.id);
if (index !== -1) {
list.value.items[index] = processListItems([addedItem])[0];
@ -768,7 +764,6 @@ const onAddItem = async () => {
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemAddedSuccess'), type: 'success' });
} catch (err) {
// Remove optimistic item on error
list.value.items = list.value.items.filter(i => i.id !== optimisticItem.id);
notificationStore.addNotification({
message: getApiErrorMessage(err, 'listDetailPage.errors.addItemFailed'),
@ -789,11 +784,10 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
if (newCompleteStatus && !originalCompleteStatus) {
item.showFirework = true;
setTimeout(() => {
// Check if item still exists and is part of the current list before resetting
if (list.value && list.value.items.find(i => i.id === item.id)) {
item.showFirework = false;
}
}, 700); // Duration of firework animation (must match CSS)
}, 700);
}
};
@ -810,8 +804,8 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
}
});
item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
triggerFirework(); // Trigger firework for offline success
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
triggerFirework();
return;
}
@ -822,9 +816,9 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
);
item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
triggerFirework(); // Trigger firework for online success
triggerFirework();
} catch (err) {
item.is_complete = originalCompleteStatus; // Revert optimistic update
item.is_complete = originalCompleteStatus;
notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'), type: 'error' });
} finally {
item.updating = false;
@ -834,11 +828,11 @@ const updateItem = async (item: ItemWithUI, newCompleteStatus: boolean) => {
const updateItemPrice = async (item: ItemWithUI) => {
if (!list.value || !item.is_complete) return;
const newPrice = item.priceInput !== undefined && String(item.priceInput).trim() !== '' ? parseFloat(String(item.priceInput)) : null;
if (item.price === newPrice?.toString()) return; // No change
if (item.price === newPrice?.toString()) return;
item.updating = true;
const originalPrice = item.price;
const originalPriceInput = item.priceInput;
item.price = newPrice?.toString() || null; // Optimistic update
item.price = newPrice?.toString() || null;
if (!isOnline.value) {
offlineStore.addAction({
@ -847,14 +841,14 @@ const updateItemPrice = async (item: ItemWithUI) => {
listId: String(list.value.id),
itemId: String(item.id),
data: {
price: newPrice ?? null, // Ensure null is sent if cleared
completed: item.is_complete // Keep completion status
price: newPrice ?? null,
completed: item.is_complete
},
version: item.version
}
});
item.updating = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' }); // Optimistic UI
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
return;
}
@ -866,7 +860,7 @@ const updateItemPrice = async (item: ItemWithUI) => {
item.version++;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemUpdatedSuccess'), type: 'success' });
} catch (err) {
item.price = originalPrice; // Revert optimistic update
item.price = originalPrice;
item.priceInput = originalPriceInput;
notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemPriceFailed'), type: 'error' });
} finally {
@ -877,7 +871,7 @@ const updateItemPrice = async (item: ItemWithUI) => {
const deleteItem = async (item: ItemWithUI) => {
if (!list.value) return;
item.deleting = true;
const originalItems = [...list.value.items]; // For potential revert
const originalItems = [...list.value.items];
if (!isOnline.value) {
offlineStore.addAction({
@ -887,7 +881,7 @@ const deleteItem = async (item: ItemWithUI) => {
itemId: String(item.id)
}
});
list.value.items = list.value.items.filter(i => i.id !== item.id); // Optimistic UI
list.value.items = list.value.items.filter(i => i.id !== item.id);
item.deleting = false;
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
return;
@ -898,7 +892,7 @@ const deleteItem = async (item: ItemWithUI) => {
list.value.items = list.value.items.filter(i => i.id !== item.id);
notificationStore.addNotification({ message: t('listDetailPage.notifications.itemDeleteSuccess'), type: 'success' });
} catch (err) {
list.value.items = originalItems; // Revert optimistic UI
list.value.items = originalItems;
notificationStore.addNotification({ message: getApiErrorMessage(err, 'listDetailPage.errors.deleteItemFailed'), type: 'error' });
} finally {
item.deleting = false;
@ -916,13 +910,13 @@ const confirmDeleteItem = (item: ItemWithUI) => {
const openOcrDialog = () => {
ocrItems.value = [];
ocrError.value = null;
resetOcrFileDialog(); // From useFileDialog
resetOcrFileDialog();
showOcrDialogState.value = true;
nextTick(() => {
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = '';
} else if (ocrFileInputRef.value) { // Native input
} else if (ocrFileInputRef.value) {
(ocrFileInputRef.value as any).value = '';
}
});
@ -966,11 +960,10 @@ const handleOcrUpload = async (file: File) => {
ocrError.value = getApiErrorMessage(err, 'listDetailPage.errors.ocrFailed');
} finally {
ocrLoading.value = false;
// Reset file input
if (ocrFileInputRef.value && ocrFileInputRef.value.$el) {
const inputElement = ocrFileInputRef.value.$el.querySelector('input[type="file"]') || ocrFileInputRef.value.$el;
if (inputElement) (inputElement as HTMLInputElement).value = '';
} else if (ocrFileInputRef.value) { // Native input
} else if (ocrFileInputRef.value) {
(ocrFileInputRef.value as any).value = '';
}
}
@ -985,7 +978,7 @@ const addOcrItems = async () => {
if (!item.name.trim()) continue;
const response = await apiClient.post(
API_ENDPOINTS.LISTS.ITEMS(String(list.value.id)),
{ name: item.name, quantity: "1" } // Default quantity 1
{ name: item.name, quantity: "1" }
);
const addedItem = response.data as Item;
list.value.items.push(processListItems([addedItem])[0]);
@ -1062,19 +1055,17 @@ const getStatusClass = (status: ExpenseSplitStatusEnum | ExpenseOverallStatusEnu
return '';
};
// Keyboard shortcut
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (event.key === 'n' && !event.ctrlKey && !event.metaKey && !event.altKey) {
const activeElement = document.activeElement;
if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA')) {
return; // Don't interfere with typing
return;
}
// Check if any modal is open, if so, don't trigger
if (showOcrDialogState.value || showCostSummaryDialog.value || showSettleModal.value || showCreateExpenseForm.value) {
return;
}
event.preventDefault();
if (itemNameInputRef.value?.$el) { // Focus the add item input
if (itemNameInputRef.value?.$el) {
(itemNameInputRef.value.$el as HTMLElement).focus();
}
}
@ -1086,7 +1077,7 @@ onMounted(() => {
error.value = null;
if (!route.params.id) {
error.value = t('listDetailPage.errors.fetchFailed'); // Generic error if no ID
error.value = t('listDetailPage.errors.fetchFailed');
pageInitialLoad.value = false;
listDetailStore.setError(t('listDetailPage.errors.fetchFailed'));
return;
@ -1126,23 +1117,20 @@ onUnmounted(() => {
});
const startItemEdit = (item: ItemWithUI) => {
// Ensure other items are not in edit mode (optional, but good for UX)
list.value?.items.forEach(i => { if (i.id !== item.id) i.isEditing = false; });
item.isEditing = true;
item.editName = item.name;
item.editQuantity = item.quantity ?? ''; // Use empty string for VInput if null/undefined
item.editQuantity = item.quantity ?? '';
};
const cancelItemEdit = (item: ItemWithUI) => {
item.isEditing = false;
// editName and editQuantity are transient, no need to reset them to anything,
// as they are re-initialized in startItemEdit.
};
const saveItemEdit = async (item: ItemWithUI) => {
if (!list.value || !item.editName || String(item.editName).trim() === '') {
notificationStore.addNotification({
message: t('listDetailPage.notifications.enterItemName'), // Re-use existing translation
message: t('listDetailPage.notifications.enterItemName'),
type: 'warning'
});
return;
@ -1152,12 +1140,9 @@ const saveItemEdit = async (item: ItemWithUI) => {
name: String(item.editName).trim(),
quantity: item.editQuantity ? String(item.editQuantity) : null,
version: item.version,
// Ensure completed status is preserved if it's part of the update endpoint implicitly or explicitly
// If your API updates 'completed' status too, you might need to send item.is_complete
// For now, assuming API endpoint for item update only takes name, quantity, version.
};
item.updating = true; // Use existing flag for visual feedback
item.updating = true;
try {
const response = await apiClient.put(
@ -1166,26 +1151,24 @@ const saveItemEdit = async (item: ItemWithUI) => {
);
const updatedItemFromApi = response.data as Item;
// Update the original item with new data from API
item.name = updatedItemFromApi.name;
item.quantity = updatedItemFromApi.quantity;
item.version = updatedItemFromApi.version;
item.is_complete = updatedItemFromApi.is_complete; // Ensure this is updated if API returns it
item.price = updatedItemFromApi.price; // And price
item.is_complete = updatedItemFromApi.is_complete;
item.price = updatedItemFromApi.price;
item.updated_at = updatedItemFromApi.updated_at;
item.isEditing = false; // Exit edit mode
item.isEditing = false;
notificationStore.addNotification({
message: t('listDetailPage.notifications.itemUpdatedSuccess'), // Re-use
message: t('listDetailPage.notifications.itemUpdatedSuccess'),
type: 'success'
});
} catch (err) {
notificationStore.addNotification({
message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'), // Re-use
message: getApiErrorMessage(err, 'listDetailPage.errors.updateItemFailed'),
type: 'error'
});
// Optionally, keep item.isEditing = true so user can correct or cancel
} finally {
item.updating = false;
}
@ -1285,17 +1268,15 @@ const handleCheckboxChange = (item: ItemWithUI, event: Event) => {
const handleDragEnd = async (evt: any) => {
if (!list.value || evt.oldIndex === evt.newIndex) return;
const originalList = [...list.value.items]; // Store original order
const originalList = [...list.value.items];
const item = list.value.items[evt.newIndex];
const newPosition = evt.newIndex + 1; // Assuming backend uses 1-based indexing for position
const newPosition = evt.newIndex + 1;
try {
// The v-model on draggable has already updated the list.value.items order optimistically.
await apiClient.put(
API_ENDPOINTS.LISTS.ITEM(String(list.value.id), String(item.id)),
{ position: newPosition, version: item.version }
);
// On success, we need to update the version of the moved item
const updatedItemInList = list.value.items.find(i => i.id === item.id);
if (updatedItemInList) {
updatedItemInList.version++;
@ -1305,7 +1286,6 @@ const handleDragEnd = async (evt: any) => {
type: 'success'
});
} catch (err) {
// Revert the order on error
list.value.items = originalList;
notificationStore.addNotification({
message: getApiErrorMessage(err, 'listDetailPage.errors.reorderItemFailed'),
@ -1321,8 +1301,6 @@ const toggleExpense = (expenseId: number) => {
if (newSet.has(expenseId)) {
newSet.delete(expenseId);
} else {
// Optional: collapse others when one is opened
// newSet.clear();
newSet.add(expenseId);
}
expandedExpenses.value = newSet;
@ -1335,8 +1313,6 @@ const isExpenseExpanded = (expenseId: number) => {
</script>
<style scoped>
/* Existing styles */
.neo-expenses-section {
padding: 0;
margin-top: 1.2rem;
@ -1344,7 +1320,6 @@ const isExpenseExpanded = (expenseId: number) => {
.neo-expense-list {
background-color: rgb(255, 248, 240);
/* Container for expense items */
border-radius: 12px;
overflow: hidden;
border: 1px solid #f0e5d8;

View File

@ -1,7 +1,5 @@
<template>
<main class="container page-padding">
<!-- <h1 class="mb-3">{{ pageTitle }}</h1> -->
<VAlert v-if="error" type="error" :message="error" class="mb-3" :closable="false">
<template #actions>
<VButton variant="danger" size="sm" @click="fetchListsAndGroups">{{ t('listsPage.retryButton') }}</VButton>
@ -70,12 +68,12 @@
import { ref, onMounted, computed, watch, onUnmounted, nextTick } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { apiClient, API_ENDPOINTS } from '@/config/api'; // Adjust path as needed
import CreateListModal from '@/components/CreateListModal.vue'; // Adjust path as needed
import { apiClient, API_ENDPOINTS } from '@/config/api';
import CreateListModal from '@/components/CreateListModal.vue';
import { useStorage } from '@vueuse/core';
import VAlert from '@/components/valerie/VAlert.vue'; // Adjust path as needed
import VCard from '@/components/valerie/VCard.vue'; // Adjust path as needed
import VButton from '@/components/valerie/VButton.vue'; // Adjust path as needed
import VAlert from '@/components/valerie/VAlert.vue';
import VCard from '@/components/valerie/VCard.vue';
import VButton from '@/components/valerie/VButton.vue';
const { t } = useI18n();
@ -187,13 +185,12 @@ const fetchAllAccessibleGroups = async () => {
allFetchedGroups.value = (response.data as Group[]);
} catch (err) {
console.error('Failed to fetch groups for modal:', err);
// Not critical for page load, modal might not show groups
}
};
const cachedLists = useStorage<(List & { items: Item[] })[]>('cached-lists', []);
const cachedTimestamp = useStorage<number>('cached-lists-timestamp', 0);
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const CACHE_DURATION = 5 * 60 * 1000;
const loadCachedData = () => {
const now = Date.now();
@ -257,7 +254,6 @@ const onListCreated = (newList: List & { items: Item[] }) => {
});
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
// Consider animating new list card in if desired
};
const toggleItem = async (list: List, item: Item) => {
@ -379,7 +375,7 @@ const navigateToList = (listId: number) => {
};
sessionStorage.setItem('listDetailShell', JSON.stringify(listShell));
}
router.push({ name: 'ListDetail', params: { id: listId } }); // Ensure 'ListDetail' route exists
router.push({ name: 'ListDetail', params: { id: listId } });
};
const prefetchListDetails = async (listId: number) => {
@ -431,10 +427,7 @@ const refetchList = async (listId: number) => {
const listIndex = lists.value.findIndex(l => l.id === listId);
if (listIndex !== -1) {
// Use direct assignment for better reactivity
lists.value[listIndex] = { ...updatedList, items: updatedList.items || [] };
// Update cache
cachedLists.value = JSON.parse(JSON.stringify(lists.value));
cachedTimestamp.value = Date.now();
} else {
@ -491,7 +484,7 @@ const checkForUpdates = async () => {
const startPolling = () => {
stopPolling();
pollingInterval.value = setInterval(checkForUpdates, 15000); // Poll every 15 seconds
pollingInterval.value = setInterval(checkForUpdates, 15000);
};
const stopPolling = () => {
@ -549,7 +542,6 @@ onUnmounted(() => {
</script>
<style scoped>
/* Ensure --light is defined in your global styles or here, e.g., :root { --light: #fff; } */
.loading-placeholder {
text-align: center;
padding: 2rem;
@ -593,7 +585,6 @@ onUnmounted(() => {
.neo-list-card:hover {
transform: translateY(-4px);
box-shadow: 6px 10px 0 var(--dark);
/* border-color: var(--secondary); */
}
.neo-list-card.touch-active {
@ -638,7 +629,6 @@ onUnmounted(() => {
opacity: 0.6;
}
/* Custom Checkbox Styles */
.neo-checkbox-label {
display: grid;
grid-template-columns: auto 1fr;
@ -718,7 +708,6 @@ onUnmounted(() => {
width: fit-content;
}
/* Animated strikethrough line */
.checkbox-text-span::before {
content: '';
position: absolute;
@ -732,7 +721,6 @@ onUnmounted(() => {
transition: transform 0.4s cubic-bezier(0.77, 0, .18, 1);
}
/* Firework particle container */
.checkbox-text-span::after {
content: '';
position: absolute;
@ -747,7 +735,6 @@ onUnmounted(() => {
pointer-events: none;
}
/* Selector fixed to target span correctly */
.neo-checkbox-label input[type="checkbox"]:checked~.checkbox-content .checkbox-text-span {
color: var(--dark);
opacity: 0.6;
@ -769,7 +756,6 @@ onUnmounted(() => {
position: relative;
}
/* Static strikethrough for items loaded as complete */
.neo-completed-static::before {
content: '';
position: absolute;
@ -833,7 +819,6 @@ onUnmounted(() => {
background: var(--light);
transform: translateY(-3px) scale(1.01);
box-shadow: 0px 6px 8px rgba(0, 0, 0, 0.05);
/* border-color: var(--secondary); */
color: var(--primary);
}
@ -885,7 +870,6 @@ onUnmounted(() => {
.neo-list-item {
margin-bottom: 0.7rem;
/* Adjusted for mobile */
}
}

View File

@ -21,7 +21,7 @@
:aria-label="t('loginPage.togglePasswordVisibilityLabel')">
<svg class="icon icon-sm">
<use :xlink:href="isPwdVisible ? '#icon-settings' : '#icon-settings'"></use>
</svg> <!-- Placeholder for visibility icons -->
</svg>
</button>
</div>
<p v-if="formErrors.password" class="form-error-text">{{ formErrors.password }}</p>
@ -49,7 +49,7 @@
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '@/stores/auth'; // Assuming path
import { useAuthStore } from '@/stores/auth';
import { useNotificationStore } from '@/stores/notifications';
import SocialLoginButtons from '@/components/SocialLoginButtons.vue';
@ -88,7 +88,7 @@ const onSubmit = async () => {
return;
}
loading.value = true;
formErrors.value.general = undefined; // Clear previous general errors
formErrors.value.general = undefined;
try {
await authStore.login(email.value, password.value);
notificationStore.addNotification({ message: t('loginPage.notifications.loginSuccess'), type: 'success' });
@ -108,7 +108,6 @@ const onSubmit = async () => {
<style scoped>
.page-container {
min-height: 100vh;
/* dvh for dynamic viewport height */
min-height: 100dvh;
padding: 1rem;
}
@ -118,8 +117,6 @@ const onSubmit = async () => {
max-width: 400px;
}
/* form-layout, w-full, mt-2, text-center styles were removed as utility classes from Valerie UI are used directly or are available. */
.link-styled {
color: var(--primary);
text-decoration: none;
@ -139,7 +136,6 @@ const onSubmit = async () => {
}
.alert.form-error-text {
/* For general error message */
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
@ -151,7 +147,6 @@ const onSubmit = async () => {
.input-with-icon-append .form-input {
padding-right: 3rem;
/* Space for the button */
}
.icon-append-btn {
@ -160,11 +155,9 @@ const onSubmit = async () => {
top: 0;
bottom: 0;
width: 3rem;
/* Width of the button */
background: transparent;
border: none;
border-left: var(--border);
/* Separator line */
cursor: pointer;
display: flex;
align-items: center;
@ -182,6 +175,4 @@ const onSubmit = async () => {
.icon-append-btn .icon {
margin: 0;
}
/* Remove default icon margin */
</style>

View File

@ -1,790 +0,0 @@
<template>
<main class="container page-padding">
<div class="page-header">
<h1 class="mb-3">{{ $t('myChoresPage.title') }}</h1>
<div class="header-controls">
<label class="toggle-switch">
<input type="checkbox" v-model="showCompleted" @change="loadAssignments">
<span class="toggle-slider"></span>
{{ $t('myChoresPage.showCompletedToggle') }}
</label>
</div>
</div>
<!-- My Assignments Timeline -->
<div v-if="assignments.length > 0" class="assignments-timeline">
<!-- Overdue Section -->
<div v-if="assignmentsByTimeline.overdue.length > 0" class="timeline-section overdue">
<div class="timeline-header">
<div class="timeline-dot overdue"></div>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.overdue') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.overdue.length }}</span>
</div>
<div class="timeline-items">
<div v-for="assignment in assignmentsByTimeline.overdue" :key="assignment.id"
class="timeline-assignment-card overdue">
<div class="assignment-timeline-marker"></div>
<div class="assignment-content">
<div class="assignment-header">
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
</span>
</div>
</div>
<div class="assignment-meta">
<div class="assignment-due-date overdue">
<span class="material-icons">schedule</span>
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
</div>
</div>
<div class="assignment-actions">
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Today Section -->
<div v-if="assignmentsByTimeline.today.length > 0" class="timeline-section today">
<div class="timeline-header">
<div class="timeline-dot today"></div>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.today') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.today.length }}</span>
</div>
<div class="timeline-items">
<div v-for="assignment in assignmentsByTimeline.today" :key="assignment.id"
class="timeline-assignment-card today">
<div class="assignment-timeline-marker"></div>
<div class="assignment-content">
<div class="assignment-header">
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
</span>
</div>
</div>
<div class="assignment-meta">
<div class="assignment-due-date today">
<span class="material-icons">today</span>
{{ $t('myChoresPage.choreCard.dueToday') }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
</div>
</div>
<div class="assignment-actions">
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- This Week Section -->
<div v-if="assignmentsByTimeline.thisWeek.length > 0" class="timeline-section this-week">
<div class="timeline-header">
<div class="timeline-dot this-week"></div>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.thisWeek') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.thisWeek.length }}</span>
</div>
<div class="timeline-items">
<div v-for="assignment in assignmentsByTimeline.thisWeek" :key="assignment.id"
class="timeline-assignment-card this-week">
<div class="assignment-timeline-marker"></div>
<div class="assignment-content">
<div class="assignment-header">
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
</span>
</div>
</div>
<div class="assignment-meta">
<div class="assignment-due-date this-week">
<span class="material-icons">date_range</span>
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
</div>
</div>
<div class="assignment-actions">
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Later Section -->
<div v-if="assignmentsByTimeline.later.length > 0" class="timeline-section later">
<div class="timeline-header">
<div class="timeline-dot later"></div>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.later') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.later.length }}</span>
</div>
<div class="timeline-items">
<div v-for="assignment in assignmentsByTimeline.later" :key="assignment.id"
class="timeline-assignment-card later">
<div class="assignment-timeline-marker"></div>
<div class="assignment-content">
<div class="assignment-header">
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
</span>
</div>
</div>
<div class="assignment-meta">
<div class="assignment-due-date later">
<span class="material-icons">schedule</span>
{{ $t('myChoresPage.choreCard.duePrefix') }} {{ formatDate(assignment.due_date) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
</div>
</div>
<div class="assignment-actions">
<button class="btn btn-success btn-sm" @click="completeAssignment(assignment)"
:disabled="isCompleting">
<span class="material-icons">check_circle</span>
{{ $t('myChoresPage.choreCard.markCompleteButton') }}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Completed Section (if showing completed) -->
<div v-if="showCompleted && assignmentsByTimeline.completed.length > 0" class="timeline-section completed">
<div class="timeline-header">
<div class="timeline-dot completed"></div>
<h2 class="timeline-title">{{ $t('myChoresPage.timelineHeaders.completed') }}</h2>
<span class="timeline-count">{{ assignmentsByTimeline.completed.length }}</span>
</div>
<div class="timeline-items">
<div v-for="assignment in assignmentsByTimeline.completed" :key="assignment.id"
class="timeline-assignment-card completed">
<div class="assignment-timeline-marker"></div>
<div class="assignment-content">
<div class="assignment-header">
<h3>{{ assignment.chore?.name }}</h3>
<div class="assignment-tags">
<span class="chore-type-tag" :class="assignment.chore?.type">
{{ assignment.chore?.type === 'personal' ? $t('myChoresPage.choreCard.personal') : $t('myChoresPage.choreCard.group') }}
</span>
<span class="chore-frequency-tag" :class="assignment.chore?.frequency">
{{ formatFrequency(assignment.chore?.frequency) }}
</span>
</div>
</div>
<div class="assignment-meta">
<div class="assignment-due-date completed">
<span class="material-icons">check_circle</span>
{{ $t('myChoresPage.choreCard.completedPrefix') }} {{ formatDate(assignment.completed_at || assignment.updated_at) }}
</div>
<div v-if="assignment.chore?.description" class="assignment-description">
{{ assignment.chore.description }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="card empty-state-card">
<svg class="icon icon-lg" aria-hidden="true">
<use xlink:href="#icon-clipboard" />
</svg>
<h3>{{ $t('myChoresPage.emptyState.title') }}</h3>
<p v-if="showCompleted">{{ $t('myChoresPage.emptyState.noAssignmentsAll') }}</p>
<p v-else>{{ $t('myChoresPage.emptyState.noAssignmentsPending') }}</p>
<router-link to="/chores" class="btn btn-primary mt-2">
<svg class="icon" aria-hidden="true">
<use xlink:href="#icon-eye" />
</svg>
{{ $t('myChoresPage.emptyState.viewAllChoresButton') }}
</router-link>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { ChoreAssignment, ChoreFrequency } from '../types/chore'
const { t } = useI18n()
const notificationStore = useNotificationStore()
// State
const assignments = ref<ChoreAssignment[]>([])
const showCompleted = ref(false)
const isCompleting = ref(false)
// Computed property for timeline grouping
const assignmentsByTimeline = computed(() => {
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const nextWeek = new Date(today)
nextWeek.setDate(nextWeek.getDate() + 7)
const timeline = {
overdue: [] as ChoreAssignment[],
today: [] as ChoreAssignment[],
thisWeek: [] as ChoreAssignment[],
later: [] as ChoreAssignment[],
completed: [] as ChoreAssignment[]
}
assignments.value.forEach(assignment => {
if (assignment.is_complete) {
timeline.completed.push(assignment)
return
}
const dueDate = new Date(assignment.due_date)
const assignmentDate = new Date(dueDate.getFullYear(), dueDate.getMonth(), dueDate.getDate())
if (assignmentDate < today) {
timeline.overdue.push(assignment)
} else if (assignmentDate.getTime() === today.getTime()) {
timeline.today.push(assignment)
} else if (assignmentDate < nextWeek) {
timeline.thisWeek.push(assignment)
} else {
timeline.later.push(assignment)
}
})
// Sort each timeline section
Object.values(timeline).forEach(section => {
section.sort((a, b) => {
const dateA = new Date(a.due_date)
const dateB = new Date(b.due_date)
if (dateA.getTime() !== dateB.getTime()) {
return dateA.getTime() - dateB.getTime()
}
return (a.chore?.name || '').localeCompare(b.chore?.name || '')
})
})
return timeline
})
// frequencyOptions is not directly used for display labels anymore, but can be kept for logic if needed elsewhere.
// const frequencyOptions = [
// { label: 'One Time', value: 'one_time' as ChoreFrequency },
// { label: 'Daily', value: 'daily' as ChoreFrequency },
// { label: 'Weekly', value: 'weekly' as ChoreFrequency },
// { label: 'Monthly', value: 'monthly' as ChoreFrequency },
// { label: 'Custom', value: 'custom' as ChoreFrequency }
// ]
// Methods
const loadAssignments = async () => {
try {
assignments.value = await choreService.getMyAssignments(showCompleted.value)
} catch (error) {
console.error('Failed to load assignments:', error)
notificationStore.addNotification({
message: t('myChoresPage.notifications.loadFailed'),
type: 'error'
})
}
}
const completeAssignment = async (assignment: ChoreAssignment) => {
if (isCompleting.value) return
isCompleting.value = true
try {
await choreService.completeAssignment(assignment.id)
notificationStore.addNotification({
message: t('myChoresPage.notifications.markedComplete', { choreName: assignment.chore?.name || '' }),
type: 'success'
})
// Reload assignments to show updated state
await loadAssignments()
} catch (error) {
console.error('Failed to complete assignment:', error)
notificationStore.addNotification({
message: t('myChoresPage.notifications.markCompleteFailed'),
type: 'error'
})
} finally {
isCompleting.value = false
}
}
const formatDate = (date: string | undefined) => {
if (!date) return t('myChoresPage.dates.unknownDate');
// Attempt to parse and format; date-fns handles various ISO and other formats.
try {
const parsedDate = new Date(date);
// Check if parsedDate is valid
if (isNaN(parsedDate.getTime())) {
// Handle cases like "YYYY-MM-DD" which might be parsed as UTC midnight
// and then potentially displayed incorrectly depending on timezone.
// If the input is just a date string without time, ensure it's treated as local.
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
const [year, month, day] = date.split('-').map(Number);
return format(new Date(year, month - 1, day), 'MMM d, yyyy');
}
return t('myChoresPage.dates.invalidDate');
}
return format(parsedDate, 'MMM d, yyyy');
} catch (e) {
// Catch any error during parsing (though Date constructor is quite forgiving)
return t('myChoresPage.dates.invalidDate');
}
}
const formatFrequency = (frequency: ChoreFrequency | undefined) => {
if (!frequency) return t('myChoresPage.frequencies.unknown');
// Assuming keys like myChoresPage.frequencies.one_time, myChoresPage.frequencies.daily
// The ChoreFrequency enum values ('one_time', 'daily', etc.) match the last part of the key.
return t(`myChoresPage.frequencies.${frequency}`);
}
// Lifecycle
onMounted(() => {
loadAssignments()
})
</script>
<style scoped>
.page-padding {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.header-controls {
display: flex;
align-items: center;
gap: 1rem;
}
.toggle-switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
cursor: pointer;
user-select: none;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 34px;
transition: 0.4s;
border: 2px solid #111;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: #111;
border-radius: 50%;
transition: 0.4s;
}
input:checked+.toggle-slider {
background-color: #007bff;
}
input:checked+.toggle-slider:before {
transform: translateX(26px);
background-color: white;
}
.mb-3 {
margin-bottom: 1.5rem;
}
.mt-2 {
margin-top: 1rem;
}
/* Timeline Layout */
.assignments-timeline {
position: relative;
margin-left: 2rem;
}
/* Timeline line */
.assignments-timeline::before {
content: '';
position: absolute;
left: -1rem;
top: 0;
bottom: 0;
width: 4px;
background: #ddd;
border-radius: 2px;
}
/* Timeline Sections */
.timeline-section {
position: relative;
margin-bottom: 2rem;
background: var(--light);
border-radius: 18px;
box-shadow: 6px 6px 0 #111;
border: 3px solid #111;
overflow: hidden;
}
.timeline-section.overdue {
border-color: #dc3545;
box-shadow: 6px 6px 0 #dc3545;
}
.timeline-section.today {
border-color: #007bff;
box-shadow: 6px 6px 0 #007bff;
}
.timeline-section.this-week {
border-color: #28a745;
box-shadow: 6px 6px 0 #28a745;
}
.timeline-section.later {
border-color: #6c757d;
box-shadow: 6px 6px 0 #6c757d;
}
.timeline-section.completed {
border-color: #28a745;
box-shadow: 6px 6px 0 #28a745;
opacity: 0.8;
}
/* Timeline Header */
.timeline-header {
padding: 1.5rem;
border-bottom: 3px solid;
background: #fafafa;
display: flex;
align-items: center;
gap: 1rem;
}
.timeline-dot {
width: 20px;
height: 20px;
border-radius: 50%;
border: 3px solid #111;
background: white;
position: relative;
z-index: 2;
}
.timeline-dot.overdue {
background: #dc3545;
border-color: #dc3545;
}
.timeline-dot.today {
background: #007bff;
border-color: #007bff;
}
.timeline-dot.this-week {
background: #28a745;
border-color: #28a745;
}
.timeline-dot.later {
background: #6c757d;
border-color: #6c757d;
}
.timeline-dot.completed {
background: #28a745;
border-color: #28a745;
}
.timeline-title {
font-size: 1.25rem;
font-weight: 700;
margin: 0;
color: #111;
}
.timeline-count {
background: #111;
color: white;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 600;
margin-left: auto;
}
/* Assignment Cards */
.timeline-assignment-card {
position: relative;
padding: 1.5rem;
border-bottom: 2px solid #eee;
background: white;
transition: all 0.2s;
}
.timeline-assignment-card:hover {
background: #f8f9fa;
}
.timeline-assignment-card:last-child {
border-bottom: none;
}
.assignment-timeline-marker {
position: absolute;
left: -2.4rem;
top: 1.5rem;
width: 12px;
height: 12px;
background: #111;
border-radius: 50%;
border: 3px solid white;
z-index: 1;
}
.assignment-content {
padding-left: 0.5rem;
}
.assignment-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.assignment-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #111;
}
.assignment-tags {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
margin-left: 1rem;
}
.chore-type-tag,
.chore-frequency-tag {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
border: 2px solid;
}
.chore-type-tag.personal {
background: #e3f2fd;
color: #1976d2;
border-color: #1976d2;
}
.chore-type-tag.group {
background: #f3e5f5;
color: #7b1fa2;
border-color: #7b1fa2;
}
.chore-frequency-tag {
background: #f5f5f5;
color: #666;
border-color: #ddd;
}
.assignment-meta {
margin-bottom: 1rem;
}
.assignment-due-date {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
margin-bottom: 0.5rem;
}
.assignment-due-date.overdue {
color: #dc3545;
}
.assignment-due-date.today {
color: #007bff;
}
.assignment-due-date.this-week {
color: #28a745;
}
.assignment-due-date.later {
color: #6c757d;
}
.assignment-due-date.completed {
color: #28a745;
}
.assignment-description {
color: #666;
font-size: 0.875rem;
line-height: 1.4;
}
.assignment-actions {
display: flex;
gap: 0.75rem;
}
.btn {
padding: 0.5rem 1rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
transition: all 0.2s;
text-decoration: none;
border: 2px solid;
font-size: 0.875rem;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.btn-success {
background: #28a745;
color: white;
border-color: #28a745;
}
.btn-success:hover:not(:disabled) {
background: #218838;
border-color: #218838;
}
.btn-primary {
background: #111;
color: white;
border-color: #111;
}
.btn-primary:hover {
background: #333;
border-color: #333;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.card {
background: white;
border: 3px solid #111;
border-radius: 18px;
box-shadow: 6px 6px 0 #111;
overflow: hidden;
}
.empty-state-card {
text-align: center;
padding: 3rem 2rem;
}
.empty-state-card .icon-lg {
width: 4rem;
height: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state-card h3 {
margin: 0 0 1rem 0;
color: #111;
}
.empty-state-card p {
margin: 0 0 1rem 0;
color: #666;
}
</style>

View File

@ -1,508 +0,0 @@
<template>
<main class="container page-padding">
<div class="row q-mb-md items-center justify-between">
<h1 class="mb-3">{{ $t('personalChoresPage.title') }}</h1>
<button class="btn btn-primary" @click="openCreateChoreModal">
<span class="material-icons">add</span>
{{ $t('personalChoresPage.newChoreButton') }}
</button>
</div>
<!-- Chores List -->
<div class="neo-grid">
<div v-for="chore in chores" :key="chore.id" class="neo-card">
<div class="neo-card-header">
<div class="row items-center justify-between">
<h3>{{ chore.name }}</h3>
<span class="neo-chore-frequency" :class="chore.frequency">
{{ formatFrequency(chore.frequency) }}
</span>
</div>
</div>
<div class="neo-card-body">
<div class="neo-chore-info">
<div class="neo-chore-due">
{{ $t('personalChoresPage.dates.duePrefix') }}: {{ formatDate(chore.next_due_date) }}
</div>
<div v-if="chore.description" class="neo-chore-description">
{{ chore.description }}
</div>
</div>
<div class="neo-card-actions">
<button class="btn btn-primary btn-sm" @click="openEditChoreModal(chore)">
<span class="material-icons">edit</span>
{{ $t('personalChoresPage.editButton') }}
</button>
<button class="btn btn-danger btn-sm" @click="confirmDeleteChore(chore)">
<span class="material-icons">delete</span>
{{ $t('personalChoresPage.deleteButton') }}
</button>
</div>
</div>
</div>
</div>
<!-- Create/Edit Chore Modal -->
<div v-if="showChoreModal" class="neo-modal">
<div class="neo-modal-content">
<div class="neo-modal-header">
<h3>{{ isEditing ? $t('personalChoresPage.modals.editChoreTitle') : $t('personalChoresPage.modals.newChoreTitle') }}</h3>
<button class="btn btn-neutral btn-icon-only" @click="showChoreModal = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="neo-modal-body">
<form @submit.prevent="onSubmit" class="neo-form">
<div class="neo-form-group">
<label for="name">{{ $t('personalChoresPage.form.nameLabel') }}</label>
<input
id="name"
v-model="choreForm.name"
type="text"
class="neo-input"
required
/>
</div>
<div class="neo-form-group">
<label for="description">{{ $t('personalChoresPage.form.descriptionLabel') }}</label>
<textarea
id="description"
v-model="choreForm.description"
class="neo-input"
rows="3"
></textarea>
</div>
<div class="neo-form-group">
<label for="frequency">{{ $t('personalChoresPage.form.frequencyLabel') }}</label>
<select
id="frequency"
v-model="choreForm.frequency"
class="neo-input"
required
>
<option v-for="option in frequencyOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div v-if="choreForm.frequency === 'custom'" class="neo-form-group">
<label for="interval">{{ $t('personalChoresPage.form.intervalLabel') }}</label>
<input
id="interval"
v-model.number="choreForm.custom_interval_days"
type="number"
class="neo-input"
min="1"
required
/>
</div>
<div class="neo-form-group">
<label for="dueDate">{{ $t('personalChoresPage.form.dueDateLabel') }}</label>
<input
id="dueDate"
v-model="choreForm.next_due_date"
type="date"
class="neo-input"
required
/>
</div>
</form>
</div>
<div class="neo-modal-footer">
<button class="btn btn-neutral" @click="showChoreModal = false">{{ $t('personalChoresPage.cancelButton') }}</button>
<button class="btn btn-primary" @click="onSubmit">{{ $t('personalChoresPage.saveButton') }}</button>
</div>
</div>
</div>
<!-- Delete Confirmation Dialog -->
<div v-if="showDeleteDialog" class="neo-modal">
<div class="neo-modal-content">
<div class="neo-modal-header">
<h3>{{ $t('personalChoresPage.modals.deleteChoreTitle') }}</h3>
<button class="btn btn-neutral btn-icon-only" @click="showDeleteDialog = false">
<span class="material-icons">close</span>
</button>
</div>
<div class="neo-modal-body">
<p>{{ $t('personalChoresPage.deleteDialog.confirmationText') }}</p>
</div>
<div class="neo-modal-footer">
<button class="btn btn-neutral" @click="showDeleteDialog = false">{{ $t('personalChoresPage.cancelButton') }}</button>
<button class="btn btn-danger" @click="deleteChore">{{ $t('personalChoresPage.deleteButton') }}</button>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { format } from 'date-fns'
import { choreService } from '../services/choreService'
import { useNotificationStore } from '../stores/notifications'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreFrequency } from '../types/chore'
const { t } = useI18n()
const notificationStore = useNotificationStore()
// State
const chores = ref<Chore[]>([])
const showChoreModal = ref(false)
const showDeleteDialog = ref(false)
const isEditing = ref(false)
const selectedChore = ref<Chore | null>(null)
const choreForm = ref<ChoreCreate>({
name: '',
description: '',
frequency: 'daily',
custom_interval_days: undefined,
next_due_date: format(new Date(), 'yyyy-MM-dd'),
type: 'personal'
})
const frequencyOptions = computed(() => [
{ label: t('personalChoresPage.frequencies.one_time'), value: 'one_time' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.daily'), value: 'daily' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.weekly'), value: 'weekly' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.monthly'), value: 'monthly' as ChoreFrequency },
{ label: t('personalChoresPage.frequencies.custom'), value: 'custom' as ChoreFrequency }
])
// Methods
const loadChores = async () => {
try {
chores.value = await choreService.getPersonalChores()
} catch (error) {
console.error('Failed to load personal chores:', error)
notificationStore.addNotification({
message: t('personalChoresPage.notifications.loadFailed'),
type: 'error'
})
}
}
const openCreateChoreModal = () => {
isEditing.value = false
choreForm.value = {
name: '',
description: '',
frequency: 'daily',
custom_interval_days: undefined,
next_due_date: format(new Date(), 'yyyy-MM-dd'),
type: 'personal'
}
showChoreModal.value = true
}
const openEditChoreModal = (chore: Chore) => {
isEditing.value = true
selectedChore.value = chore
choreForm.value = { ...chore, type: 'personal' } // Ensure type is personal
showChoreModal.value = true
}
const onSubmit = async () => {
try {
const payload: ChoreCreate | ChoreUpdate = {
...choreForm.value,
type: 'personal' // Always personal for this page
};
if (isEditing.value && selectedChore.value) {
await choreService.updatePersonalChore(selectedChore.value.id, payload as ChoreUpdate)
notificationStore.addNotification({
message: t('personalChoresPage.notifications.updateSuccess'),
type: 'success'
})
} else {
await choreService.createPersonalChore(payload as ChoreCreate)
notificationStore.addNotification({
message: t('personalChoresPage.notifications.createSuccess'),
type: 'success'
})
}
showChoreModal.value = false
loadChores()
} catch (error) {
console.error('Failed to save personal chore:', error)
notificationStore.addNotification({
message: t('personalChoresPage.notifications.saveFailed'), // Generic message
type: 'error'
})
}
}
const confirmDeleteChore = (chore: Chore) => {
selectedChore.value = chore
showDeleteDialog.value = true
}
const deleteChore = async () => {
if (!selectedChore.value) return
try {
await choreService.deletePersonalChore(selectedChore.value.id)
showDeleteDialog.value = false
notificationStore.addNotification({
message: t('personalChoresPage.notifications.deleteSuccess'),
type: 'success'
})
loadChores()
} catch (error) {
console.error('Failed to delete personal chore:', error)
notificationStore.addNotification({
message: t('personalChoresPage.notifications.deleteFailed'),
type: 'error'
})
}
}
const formatDate = (date: string | undefined) => {
if (!date) return ''; // Or perhaps a specific 'Unknown Date' string if desired: t('personalChoresPage.dates.unknownDate')
try {
// Handles both 'YYYY-MM-DD' and full ISO with 'T'
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) {
// Explicitly handle 'YYYY-MM-DD' if new Date() struggles with it directly as local time
if (/^\d{4}-\d{2}-\d{2}$/.test(date)) {
const [year, month, day] = date.split('-').map(Number);
return format(new Date(year, month - 1, day), 'MMM d, yyyy');
}
return t('personalChoresPage.dates.invalidDate');
}
return format(parsedDate, 'MMM d, yyyy');
} catch (e) {
return t('personalChoresPage.dates.invalidDate');
}
}
const formatFrequency = (frequency: ChoreFrequency | undefined) => {
if (!frequency) return t('personalChoresPage.frequencies.unknown');
// Use the value from frequencyOptions which is now translated
const option = frequencyOptions.value.find(opt => opt.value === frequency);
return option ? option.label : t(`personalChoresPage.frequencies.${frequency}`); // Fallback if somehow not in options
}
// Lifecycle
onMounted(() => {
loadChores()
})
</script>
<style scoped>
.page-padding {
padding: 1rem;
max-width: 1200px;
margin: 0 auto;
}
.mb-3 {
margin-bottom: 1.5rem;
}
/* Neo Grid Layout */
.neo-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
/* Neo Card Styles */
.neo-card {
border-radius: 18px;
box-shadow: 6px 6px 0 #111;
background: var(--light);
border: 3px solid #111;
overflow: hidden;
}
.neo-card-header {
padding: 1.5rem;
border-bottom: 3px solid #111;
background: #fafafa;
}
.neo-card-header h3 {
font-weight: 900;
font-size: 1.25rem;
margin: 0;
letter-spacing: 0.5px;
}
.neo-card-body {
padding: 1.5rem;
}
.neo-card-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
}
/* Chore Info Styles */
.neo-chore-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.neo-chore-due {
font-size: 0.875rem;
color: #666;
}
.neo-chore-description {
margin-top: 0.5rem;
color: #444;
}
.neo-chore-frequency {
font-size: 0.875rem;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-weight: 600;
}
.neo-chore-frequency.one_time { background: #e0e0e0; }
.neo-chore-frequency.daily { background: #bbdefb; color: #1565c0; }
.neo-chore-frequency.weekly { background: #c8e6c9; color: #2e7d32; }
.neo-chore-frequency.monthly { background: #e1bee7; color: #7b1fa2; }
.neo-chore-frequency.custom { background: #ffe0b2; color: #ef6c00; }
/* Modal Styles */
.neo-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.neo-modal-content {
background: white;
border-radius: 18px;
box-shadow: 6px 6px 0 #111;
border: 3px solid #111;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.neo-modal-header {
padding: 1.5rem;
border-bottom: 3px solid #111;
background: #fafafa;
display: flex;
justify-content: space-between;
align-items: center;
}
.neo-modal-body {
padding: 1.5rem;
}
.neo-modal-footer {
padding: 1.5rem;
border-top: 3px solid #111;
background: #fafafa;
display: flex;
justify-content: flex-end;
gap: 1rem;
}
/* Form Styles */
.neo-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.neo-form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.neo-form-group label {
font-weight: 600;
}
.neo-input {
padding: 0.75rem;
border: 2px solid #111;
border-radius: 8px;
font-size: 1rem;
background: white;
}
.neo-input:focus {
outline: none;
border-color: var(--primary-color);
}
/* Button Styles */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border: 2px solid #111;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s ease-in-out;
}
.btn:hover {
transform: translateY(-2px);
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-neutral {
background: #f8f9fa;
color: #111;
}
.btn-sm {
padding: 0.5rem 1rem;
font-size: 0.875rem;
}
.btn-icon-only {
padding: 0.5rem;
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.neo-grid {
grid-template-columns: 1fr;
}
.neo-modal-content {
width: 95%;
}
}
</style>

View File

@ -1,4 +1,3 @@
// src/router/index.ts
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
import routes from './routes'
import { useAuthStore } from '../stores/auth'
@ -15,14 +14,13 @@ const router = createRouter({
})
router.beforeEach(async (to, from, next) => {
// Auth guard logic
const authStore = useAuthStore()
const isAuthenticated = authStore.isAuthenticated
const publicRoutes = ['/auth/login', '/auth/signup', '/auth/callback'] // Added callback route
const publicRoutes = ['/auth/login', '/auth/signup', '/auth/callback']
const requiresAuth = !publicRoutes.includes(to.path)
if (requiresAuth && !isAuthenticated) {
next({ path: '/auth/login', query: { redirect: to.fullPath } }) // Fixed login path with leading slash
next({ path: '/auth/login', query: { redirect: to.fullPath } })
} else if (!requiresAuth && isAuthenticated) {
next({ path: '/' })
} else {

View File

@ -1,11 +1,9 @@
// src/router/routes.ts
// Adapt paths to new component locations
import type { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('../layouts/MainLayout.vue'), // Use .. alias
component: () => import('../layouts/MainLayout.vue'),
children: [
{ path: '', redirect: '/lists' },
{
@ -37,7 +35,7 @@ const routes: RouteRecordRaw[] = [
{
path: 'groups/:groupId/lists',
name: 'GroupLists',
component: () => import('../pages/ListsPage.vue'), // Reusing ListsPage
component: () => import('../pages/ListsPage.vue'),
props: true,
meta: { keepAlive: true },
},
@ -60,16 +58,10 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/ChoresPage.vue'),
meta: { requiresAuth: true, keepAlive: false },
},
{
path: '/personal-chores',
name: 'PersonalChores',
component: () => import('@/pages/PersonalChoresPage.vue'),
meta: { requiresAuth: true, keepAlive: false },
},
],
},
{
path: '/auth', // Group auth routes under a common path for clarity (optional)
path: '/auth',
component: () => import('../layouts/AuthLayout.vue'),
children: [
{ path: 'login', name: 'Login', component: () => import('../pages/LoginPage.vue') },
@ -81,10 +73,10 @@ const routes: RouteRecordRaw[] = [
},
],
},
// {
// path: '/:catchAll(.*)*', name: '404',
// component: () => import('../pages/ErrorNotFound.vue'),
// },
{
path: '/:catchAll(.*)*', name: '404',
component: () => import('../pages/ErrorNotFound.vue'),
},
]
export default routes

View File

@ -1,30 +1,26 @@
import { api } from './api'
import type { Chore, ChoreCreate, ChoreUpdate, ChoreType, ChoreAssignment, ChoreAssignmentCreate, ChoreAssignmentUpdate, ChoreHistory } from '../types/chore'
import { groupService } from './groupService'
import type { Group } from './groupService'
import { apiClient, API_ENDPOINTS } from '@/config/api'
import type { Group } from '@/types/group'
export const choreService = {
async getAllChores(): Promise<Chore[]> {
try {
// Use the new optimized endpoint that returns all chores in a single request
const response = await api.get('/api/v1/chores/all')
return response.data
} catch (error) {
console.error('Failed to get all chores via optimized endpoint, falling back to individual calls:', error)
// Fallback to the original method if the new endpoint fails
return this.getAllChoresFallback()
}
},
// Fallback method using individual API calls (kept for compatibility)
async getAllChoresFallback(): Promise<Chore[]> {
let allChores: Chore[] = []
try {
const personalChores = await this.getPersonalChores()
allChores = allChores.concat(personalChores)
// Fetch chores for all groups
const userGroups: Group[] = await groupService.getUserGroups()
for (const group of userGroups) {
try {
@ -32,24 +28,20 @@ export const choreService = {
allChores = allChores.concat(groupChores)
} catch (groupError) {
console.error(`Failed to get chores for group ${group.id} (${group.name}):`, groupError)
// Continue fetching chores for other groups
}
}
} catch (error) {
console.error('Failed to get all chores:', error)
// Optionally re-throw or handle as per application's error strategy
throw error
}
return allChores
},
// Group Chores (specific fetch, might still be used internally or for specific group views)
async getChores(groupId: number): Promise<Chore[]> {
const response = await api.get(`/api/v1/chores/groups/${groupId}/chores`)
return response.data
},
// Unified createChore
async createChore(chore: ChoreCreate): Promise<Chore> {
if (chore.type === 'personal') {
const response = await api.post('/api/v1/chores/personal', chore)
@ -62,10 +54,8 @@ export const choreService = {
}
},
// Unified updateChore
async updateChore(choreId: number, chore: ChoreUpdate): Promise<Chore> {
if (chore.type === 'personal') {
// For personal chores, group_id is not part of the route
const response = await api.put(`/api/v1/chores/personal/${choreId}`, chore)
return response.data
} else if (chore.type === 'group' && chore.group_id) {
@ -79,7 +69,6 @@ export const choreService = {
}
},
// Unified deleteChore
async deleteChore(choreId: number, choreType: ChoreType, groupId?: number): Promise<void> {
if (choreType === 'personal') {
await api.delete(`/api/v1/chores/personal/${choreId}`)
@ -90,95 +79,59 @@ export const choreService = {
}
},
// Personal Chores (specific fetch, used by getAllChores)
async getPersonalChores(): Promise<Chore[]> {
const response = await api.get('/api/v1/chores/personal')
return response.data
},
// === CHORE ASSIGNMENT METHODS ===
// Create chore assignment
async createAssignment(assignment: ChoreAssignmentCreate): Promise<ChoreAssignment> {
const response = await api.post('/api/v1/chores/assignments', assignment)
return response.data
},
// Get user's assignments
async getMyAssignments(includeCompleted: boolean = false): Promise<ChoreAssignment[]> {
const response = await api.get(`/api/v1/chores/assignments/my?include_completed=${includeCompleted}`)
return response.data
},
// Get assignments for a specific chore
async getChoreAssignments(choreId: number): Promise<ChoreAssignment[]> {
const response = await api.get(`/api/v1/chores/chores/${choreId}/assignments`)
return response.data
},
// Update assignment
async updateAssignment(assignmentId: number, update: ChoreAssignmentUpdate): Promise<ChoreAssignment> {
const response = await apiClient.put(`/api/v1/chores/assignments/${assignmentId}`, update)
return response.data
},
// Delete assignment
async deleteAssignment(assignmentId: number): Promise<void> {
await api.delete(`/api/v1/chores/assignments/${assignmentId}`)
},
// Mark assignment as complete (convenience method)
async completeAssignment(assignmentId: number): Promise<ChoreAssignment> {
const response = await api.patch(`/api/v1/chores/assignments/${assignmentId}/complete`)
return response.data
},
// Removed createPersonalChore, updatePersonalChore, deletePersonalChore
// They are merged into the unified methods above.
// Original group-specific methods might be kept if there are pages
// that specifically deal ONLY with a single group's chores and pass groupId.
// For ChoresPage.vue, we'll use the unified methods.
// The original group chore methods are below, we can decide to remove them if
// the unified methods cover all use cases and no other part of the app uses them directly.
// async createChore(groupId: number, chore: ChoreCreate): Promise<Chore> { // Original group create
// const response = await api.post(`/api/v1/chores/groups/${groupId}/chores`, chore)
// return response.data
// },
async _original_updateGroupChore(
groupId: number,
choreId: number,
chore: ChoreUpdate,
): Promise<Chore> {
// Renamed original
const response = await api.put(`/api/v1/chores/groups/${groupId}/chores/${choreId}`, chore)
return response.data
},
async _original_deleteGroupChore(groupId: number, choreId: number): Promise<void> {
// Renamed original
await api.delete(`/api/v1/chores/groups/${groupId}/chores/${choreId}`)
},
// Personal Chores (getPersonalChores is kept as it's used by getAllChores)
// async getPersonalChores(): Promise<Chore[]> { ... }
// async createPersonalChore(chore: ChoreCreate): Promise<Chore> { // Removed
// const response = await api.post('/api/v1/chores/personal', chore)
// return response.data
// },
async _updatePersonalChore(choreId: number, chore: ChoreUpdate): Promise<Chore> {
// Renamed original for safety, to be removed
const response = await api.put(`/api/v1/chores/personal/${choreId}`, chore)
return response.data
},
async _deletePersonalChore(choreId: number): Promise<void> {
// Renamed original for safety, to be removed
await api.delete(`/api/v1/chores/personal/${choreId}`)
},

View File

@ -2,21 +2,6 @@ import { apiClient, API_ENDPOINTS } from '@/config/api';
import type { Group } from '@/types/group';
import type { ChoreHistory } from '@/types/chore';
// Define Group interface matching backend schema
export interface Group {
id: number
name: string
description?: string
created_at: string
updated_at: string
owner_id: number
members: {
id: number
email: string
role: 'owner' | 'member'
}[]
}
export const groupService = {
async getUserGroups(): Promise<Group[]> {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE);
@ -32,8 +17,14 @@ export const groupService = {
return response.data;
},
// Add other group-related service methods here, e.g.:
// async getGroupDetails(groupId: number): Promise<Group> { ... }
// async createGroup(groupData: any): Promise<Group> { ... }
// async addUserToGroup(groupId: number, userId: number): Promise<void> { ... }
async getGroupDetails(groupId: number): Promise<Group> {
const response = await apiClient.get(API_ENDPOINTS.GROUPS.BASE + '/' + groupId);
return response.data;
},
async createGroup(groupData: Group): Promise<Group> {
const response = await apiClient.post(API_ENDPOINTS.GROUPS.BASE, groupData);
return response.data;
},
}