Phase 3.1: Enhanced Chore Logging and Reporting System

This commit is contained in:
2026-02-05 12:33:51 +11:00
commit e3cae7bfbb
178 changed files with 30105 additions and 0 deletions

View File

@@ -0,0 +1 @@
# API v1 package

196
backend/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,196 @@
"""Authentication endpoints."""
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from typing import List
from app.core.database import get_db
from app.core.security import verify_password, create_access_token, decode_access_token, get_password_hash
from app.core.config import settings
from app.models.user import User
from app.schemas.auth import Token
from app.schemas.user import UserCreate, UserResponse, UserUpdate, UserAdminUpdate
router = APIRouter()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> User:
"""Get the current authenticated user."""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
payload = decode_access_token(token)
if payload is None:
raise credentials_exception
username: str = payload.get("sub")
if username is None:
raise credentials_exception
user = db.query(User).filter(User.username == username).first()
if user is None:
raise credentials_exception
return user
def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User:
"""Get current user and verify they are an admin."""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(user_data: UserCreate, db: Session = Depends(get_db)):
"""Register a new user."""
# Check if username already exists
if db.query(User).filter(User.username == user_data.username).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already registered"
)
# Check if email already exists
if db.query(User).filter(User.email == user_data.email).first():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
db_user = User(
username=user_data.username,
email=user_data.email,
full_name=user_data.full_name,
discord_id=user_data.discord_id,
profile_picture=user_data.profile_picture,
hashed_password=get_password_hash(user_data.password),
is_active=True,
is_admin=False
)
db.add(db_user)
db.commit()
db.refresh(db_user)
return db_user
@router.post("/login", response_model=Token)
async def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
"""Login and get access token."""
user = db.query(User).filter(User.username == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username},
expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
@router.get("/me", response_model=UserResponse)
async def get_current_user_info(current_user: User = Depends(get_current_user)):
"""Get current user information."""
return current_user
@router.put("/me", response_model=UserResponse)
async def update_current_user(
user_update: UserUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update current user's own profile."""
update_data = user_update.model_dump(exclude_unset=True)
# Hash password if provided
if "password" in update_data and update_data["password"]:
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
# Check email uniqueness if being updated
if "email" in update_data:
existing_user = db.query(User).filter(
User.email == update_data["email"],
User.id != current_user.id
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already in use"
)
# Update user fields
for field, value in update_data.items():
setattr(current_user, field, value)
db.commit()
db.refresh(current_user)
return current_user
@router.get("/users", response_model=List[UserResponse])
async def list_users(
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin_user)
):
"""Admin endpoint to list all users."""
users = db.query(User).all()
return users
@router.put("/users/{user_id}", response_model=UserResponse)
async def update_user_admin(
user_id: int,
user_update: UserAdminUpdate,
db: Session = Depends(get_db),
admin_user: User = Depends(get_current_admin_user)
):
"""Admin endpoint to update any user."""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
update_data = user_update.model_dump(exclude_unset=True)
# Hash password if provided
if "password" in update_data and update_data["password"]:
update_data["hashed_password"] = get_password_hash(update_data.pop("password"))
# Check email uniqueness if being updated
if "email" in update_data:
existing_user = db.query(User).filter(
User.email == update_data["email"],
User.id != user.id
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already in use"
)
# Update user fields
for field, value in update_data.items():
setattr(user, field, value)
db.commit()
db.refresh(user)
return user

View File

@@ -0,0 +1,397 @@
"""Chore Completion Log API endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from sqlalchemy import func, and_
from typing import List, Optional
from datetime import datetime, timedelta
from app.core.database import get_db
from app.api.v1.auth import get_current_user
from app.models.user import User
from app.models.chore import Chore
from app.models.chore_assignment import ChoreAssignment
from app.models.chore_completion_log import ChoreCompletionLog
from app.schemas import chore_completion_log as log_schemas
router = APIRouter()
def enrich_completion_log(db: Session, log: ChoreCompletionLog) -> dict:
"""Add related information to completion log."""
# Get chore info
chore = db.query(Chore).filter(Chore.id == log.chore_id).first()
# Get user info
user = db.query(User).filter(User.id == log.user_id).first()
# Get verified_by info if exists
verified_by_name = None
if log.verified_by_user_id:
verified_by = db.query(User).filter(User.id == log.verified_by_user_id).first()
if verified_by:
verified_by_name = verified_by.full_name or verified_by.username
return {
"id": log.id,
"chore_id": log.chore_id,
"user_id": log.user_id,
"completed_at": log.completed_at,
"notes": log.notes,
"verified_by_user_id": log.verified_by_user_id,
"created_at": log.created_at,
"chore_title": chore.title if chore else None,
"user_name": user.full_name or user.username if user else None,
"user_avatar": user.avatar_url if user else None,
"verified_by_name": verified_by_name
}
@router.post("/{chore_id}/complete", response_model=log_schemas.ChoreCompletionLog, status_code=status.HTTP_201_CREATED)
def complete_chore(
chore_id: int,
notes: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Log a chore completion.
Creates a completion log entry and updates the chore assignment.
This is the primary endpoint for completing chores.
"""
# Check if chore exists
chore = db.query(Chore).filter(Chore.id == chore_id).first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found"
)
# Check if user is assigned to this chore
assignment = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id,
ChoreAssignment.user_id == current_user.id
).first()
if not assignment:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You are not assigned to this chore"
)
# Create completion log
completion_log = ChoreCompletionLog(
chore_id=chore_id,
user_id=current_user.id,
completed_at=datetime.utcnow(),
notes=notes
)
db.add(completion_log)
# Update assignment completed_at
assignment.completed_at = datetime.utcnow()
# Check if all assignments are completed
all_assignments = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id
).all()
if all(a.completed_at is not None for a in all_assignments):
chore.completed_at = datetime.utcnow()
chore.status = "completed"
db.commit()
db.refresh(completion_log)
return enrich_completion_log(db, completion_log)
@router.get("/completions", response_model=List[log_schemas.ChoreCompletionLog])
def get_completion_logs(
skip: int = 0,
limit: int = 100,
chore_id: Optional[int] = Query(None, description="Filter by chore ID"),
user_id: Optional[int] = Query(None, description="Filter by user ID"),
start_date: Optional[datetime] = Query(None, description="Filter completions after this date"),
end_date: Optional[datetime] = Query(None, description="Filter completions before this date"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get chore completion logs with optional filters.
- **chore_id**: Filter by specific chore
- **user_id**: Filter by specific user
- **start_date**: Filter completions after this date
- **end_date**: Filter completions before this date
"""
query = db.query(ChoreCompletionLog)
# Apply filters
if chore_id:
query = query.filter(ChoreCompletionLog.chore_id == chore_id)
if user_id:
query = query.filter(ChoreCompletionLog.user_id == user_id)
if start_date:
query = query.filter(ChoreCompletionLog.completed_at >= start_date)
if end_date:
query = query.filter(ChoreCompletionLog.completed_at <= end_date)
# Order by most recent first
query = query.order_by(ChoreCompletionLog.completed_at.desc())
logs = query.offset(skip).limit(limit).all()
# Enrich with related data
return [enrich_completion_log(db, log) for log in logs]
@router.get("/reports/weekly", response_model=log_schemas.WeeklyChoreReport)
def get_weekly_report(
user_id: Optional[int] = Query(None, description="Get report for specific user (omit for family-wide)"),
weeks_ago: int = Query(0, description="Number of weeks ago (0 = current week)"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Generate a weekly chore completion report.
- **user_id**: Optional - get report for specific user
- **weeks_ago**: Which week to report on (0 = current week, 1 = last week, etc.)
Returns comprehensive statistics including:
- Total completions
- Completions by user
- Completions by chore
- Completions by day
- Top performers
- Recent completions
"""
# Calculate week boundaries
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_of_week = today - timedelta(days=today.weekday()) - timedelta(weeks=weeks_ago)
end_of_week = start_of_week + timedelta(days=7)
# Base query
query = db.query(ChoreCompletionLog).filter(
and_(
ChoreCompletionLog.completed_at >= start_of_week,
ChoreCompletionLog.completed_at < end_of_week
)
)
# Apply user filter if specified
if user_id:
query = query.filter(ChoreCompletionLog.user_id == user_id)
logs = query.all()
# Calculate statistics
total_completions = len(logs)
# Completions by user
completions_by_user = {}
for log in logs:
user = db.query(User).filter(User.id == log.user_id).first()
if user:
username = user.full_name or user.username
completions_by_user[username] = completions_by_user.get(username, 0) + 1
# Completions by chore
completions_by_chore = {}
for log in logs:
chore = db.query(Chore).filter(Chore.id == log.chore_id).first()
if chore:
completions_by_chore[chore.title] = completions_by_chore.get(chore.title, 0) + 1
# Completions by day
completions_by_day = {}
for log in logs:
day_name = log.completed_at.strftime("%A")
completions_by_day[day_name] = completions_by_day.get(day_name, 0) + 1
# Top performers
user_stats = []
for user_name, count in completions_by_user.items():
user = db.query(User).filter(
(User.full_name == user_name) | (User.username == user_name)
).first()
user_stats.append({
"username": user_name,
"count": count,
"avatar_url": user.avatar_url if user else None
})
user_stats.sort(key=lambda x: x["count"], reverse=True)
top_performers = user_stats[:5] # Top 5 performers
# Recent completions (last 10)
recent_logs = sorted(logs, key=lambda x: x.completed_at, reverse=True)[:10]
recent_completions = [enrich_completion_log(db, log) for log in recent_logs]
return {
"start_date": start_of_week,
"end_date": end_of_week,
"total_completions": total_completions,
"completions_by_user": completions_by_user,
"completions_by_chore": completions_by_chore,
"completions_by_day": completions_by_day,
"top_performers": top_performers,
"recent_completions": recent_completions
}
@router.get("/reports/user/{user_id}", response_model=log_schemas.UserChoreStats)
def get_user_stats(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get comprehensive statistics for a specific user.
Returns:
- Total completions (all time)
- Completions this week
- Completions this month
- Favorite chore (most completed)
- Recent completions
"""
# Check if user exists
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Total completions
total_completions = db.query(ChoreCompletionLog).filter(
ChoreCompletionLog.user_id == user_id
).count()
# This week
today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
start_of_week = today - timedelta(days=today.weekday())
completions_this_week = db.query(ChoreCompletionLog).filter(
and_(
ChoreCompletionLog.user_id == user_id,
ChoreCompletionLog.completed_at >= start_of_week
)
).count()
# This month
start_of_month = today.replace(day=1)
completions_this_month = db.query(ChoreCompletionLog).filter(
and_(
ChoreCompletionLog.user_id == user_id,
ChoreCompletionLog.completed_at >= start_of_month
)
).count()
# Favorite chore (most completed)
favorite_chore = None
chore_counts = db.query(
ChoreCompletionLog.chore_id,
func.count(ChoreCompletionLog.id).label('count')
).filter(
ChoreCompletionLog.user_id == user_id
).group_by(
ChoreCompletionLog.chore_id
).order_by(
func.count(ChoreCompletionLog.id).desc()
).first()
if chore_counts:
chore = db.query(Chore).filter(Chore.id == chore_counts[0]).first()
if chore:
favorite_chore = chore.title
# Recent completions
recent_logs = db.query(ChoreCompletionLog).filter(
ChoreCompletionLog.user_id == user_id
).order_by(
ChoreCompletionLog.completed_at.desc()
).limit(10).all()
recent_completions = [enrich_completion_log(db, log) for log in recent_logs]
return {
"user_id": user.id,
"username": user.username,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"total_completions": total_completions,
"completions_this_week": completions_this_week,
"completions_this_month": completions_this_month,
"favorite_chore": favorite_chore,
"recent_completions": recent_completions
}
@router.delete("/completions/{log_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_completion_log(
log_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Delete a completion log entry (admin only or log owner).
Useful for correcting mistakes or removing accidental completions.
"""
log = db.query(ChoreCompletionLog).filter(ChoreCompletionLog.id == log_id).first()
if not log:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Completion log not found"
)
# Only admin or the user who completed can delete
if not current_user.is_admin and log.user_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete this completion log"
)
db.delete(log)
db.commit()
return None
@router.post("/completions/{log_id}/verify", response_model=log_schemas.ChoreCompletionLog)
def verify_completion(
log_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Verify a chore completion (requires different user than completer).
Useful for parents verifying kids' chores, or quality checks.
"""
log = db.query(ChoreCompletionLog).filter(ChoreCompletionLog.id == log_id).first()
if not log:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Completion log not found"
)
# Can't verify your own completion
if log.user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You cannot verify your own completion"
)
log.verified_by_user_id = current_user.id
db.commit()
db.refresh(log)
return enrich_completion_log(db, log)

View File

@@ -0,0 +1,336 @@
"""Chores API endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, date
from app.core.database import get_db
from app.api.v1.auth import get_current_user
from app.models.user import User
from app.models.chore import Chore, ChoreStatus
from app.models.chore_assignment import ChoreAssignment
from app.schemas import chore as chore_schemas
router = APIRouter()
def is_birthday_today(user: User) -> bool:
"""Check if today is the user's birthday."""
if not user.birthday:
return False
today = date.today()
return user.birthday.month == today.month and user.birthday.day == today.day
def get_chore_with_assignments(db: Session, chore: Chore) -> dict:
"""Convert chore to dict with assignment details."""
# Get all assignments for this chore
assignments = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore.id
).all()
# Build assigned users list with completion status
assigned_users = []
for assignment in assignments:
user = db.query(User).filter(User.id == assignment.user_id).first()
if user:
assigned_users.append({
"id": user.id,
"username": user.username,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"birthday": user.birthday,
"completed_at": assignment.completed_at
})
# Convert chore to dict
chore_dict = {
"id": chore.id,
"title": chore.title,
"description": chore.description,
"room": chore.room,
"frequency": chore.frequency,
"points": chore.points,
"image_url": chore.image_url,
"assignment_type": chore.assignment_type,
"status": chore.status,
"due_date": chore.due_date,
"completed_at": chore.completed_at,
"created_at": chore.created_at,
"updated_at": chore.updated_at,
"assigned_users": assigned_users,
"assigned_user_id": chore.assigned_user_id # Legacy compatibility
}
return chore_dict
@router.get("", response_model=List[chore_schemas.Chore])
def get_chores(
skip: int = 0,
limit: int = 100,
user_id: Optional[int] = Query(None, description="Filter by assigned user ID"),
exclude_birthdays: bool = Query(False, description="Exclude chores for users with birthdays today"),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Get all chores.
- **user_id**: Filter chores assigned to specific user
- **exclude_birthdays**: Skip chores for users with birthdays today
"""
query = db.query(Chore)
# Apply user filter if specified
if user_id:
# Find chores assigned to this user through the assignments table
assignment_ids = db.query(ChoreAssignment.chore_id).filter(
ChoreAssignment.user_id == user_id
).all()
chore_ids = [aid[0] for aid in assignment_ids]
query = query.filter(Chore.id.in_(chore_ids))
chores = query.offset(skip).limit(limit).all()
# Build response with assignments
result = []
for chore in chores:
chore_data = get_chore_with_assignments(db, chore)
# Filter out if birthday exclusion is enabled
if exclude_birthdays:
# Skip if any assigned user has birthday today
skip_chore = False
for assigned_user in chore_data["assigned_users"]:
user = db.query(User).filter(User.id == assigned_user["id"]).first()
if user and is_birthday_today(user):
skip_chore = True
break
if skip_chore:
continue
result.append(chore_data)
return result
@router.get("/{chore_id}", response_model=chore_schemas.Chore)
def get_chore(
chore_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get a specific chore by ID."""
chore = db.query(Chore).filter(Chore.id == chore_id).first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found"
)
return get_chore_with_assignments(db, chore)
@router.post("", response_model=chore_schemas.Chore, status_code=status.HTTP_201_CREATED)
def create_chore(
chore_in: chore_schemas.ChoreCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Create a new chore with multiple user assignments."""
# Extract user IDs before creating chore
assigned_user_ids = chore_in.assigned_user_ids or []
# Create chore without assigned_user_ids (not a DB column)
chore_data = chore_in.model_dump(exclude={'assigned_user_ids'})
chore = Chore(**chore_data)
db.add(chore)
db.commit()
db.refresh(chore)
# Create assignments for each user
for user_id in assigned_user_ids:
# Verify user exists
user = db.query(User).filter(User.id == user_id).first()
if not user:
continue
assignment = ChoreAssignment(
chore_id=chore.id,
user_id=user_id
)
db.add(assignment)
db.commit()
return get_chore_with_assignments(db, chore)
@router.put("/{chore_id}", response_model=chore_schemas.Chore)
def update_chore(
chore_id: int,
chore_in: chore_schemas.ChoreUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Update a chore (admin only or assigned user for status updates).
Admin users can update all fields.
Non-admin users can only update status for chores assigned to them.
"""
chore = db.query(Chore).filter(Chore.id == chore_id).first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found"
)
update_data = chore_in.model_dump(exclude_unset=True, exclude={'assigned_user_ids'})
# Check permissions
is_admin = current_user.is_admin
is_assigned = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id,
ChoreAssignment.user_id == current_user.id
).first() is not None
if not is_admin and not is_assigned:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to update this chore"
)
# Non-admins can only update status
if not is_admin:
allowed_fields = {'status'}
update_data = {k: v for k, v in update_data.items() if k in allowed_fields}
# Handle status change to completed
if update_data.get("status") == ChoreStatus.COMPLETED:
# Mark assignment as completed for current user
assignment = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id,
ChoreAssignment.user_id == current_user.id
).first()
if assignment:
assignment.completed_at = datetime.utcnow()
# If all assignments are completed, mark chore as completed
all_assignments = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id
).all()
if all(a.completed_at is not None for a in all_assignments):
chore.completed_at = datetime.utcnow()
chore.status = ChoreStatus.COMPLETED
# Update chore fields (admins only)
if is_admin:
for field, value in update_data.items():
# Convert empty string to None for datetime fields
if field == 'due_date' and value == '':
value = None
setattr(chore, field, value)
# Handle user assignments update
if 'assigned_user_ids' in chore_in.model_dump(exclude_unset=True):
assigned_user_ids = chore_in.assigned_user_ids or []
# Remove old assignments
db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id
).delete()
# Add new assignments
for user_id in assigned_user_ids:
user = db.query(User).filter(User.id == user_id).first()
if not user:
continue
assignment = ChoreAssignment(
chore_id=chore.id,
user_id=user_id
)
db.add(assignment)
db.commit()
db.refresh(chore)
return get_chore_with_assignments(db, chore)
@router.delete("/{chore_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_chore(
chore_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete a chore (admin only)."""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can delete chores"
)
chore = db.query(Chore).filter(Chore.id == chore_id).first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found"
)
db.delete(chore)
db.commit()
return None
@router.post("/{chore_id}/assign", response_model=chore_schemas.Chore)
def assign_users_to_chore(
chore_id: int,
user_ids: List[int],
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Assign multiple users to a chore (admin only).
Replaces existing assignments.
"""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can assign chores"
)
chore = db.query(Chore).filter(Chore.id == chore_id).first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found"
)
# Remove existing assignments
db.query(ChoreAssignment).filter(ChoreAssignment.chore_id == chore_id).delete()
# Add new assignments
for user_id in user_ids:
user = db.query(User).filter(User.id == user_id).first()
if not user:
continue
assignment = ChoreAssignment(
chore_id=chore.id,
user_id=user_id
)
db.add(assignment)
db.commit()
db.refresh(chore)
return get_chore_with_assignments(db, chore)

View File

@@ -0,0 +1,297 @@
"""Public API endpoints for kiosk view (no authentication required)."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import date
from app.core.database import get_db
from app.models.user import User
from app.models.chore import Chore
from app.models.chore_assignment import ChoreAssignment
from app.models.chore_completion_log import ChoreCompletionLog
router = APIRouter()
@router.get("/users")
async def get_public_users(db: Session = Depends(get_db)):
"""Get all active users (public endpoint for kiosk)."""
users = db.query(User).filter(User.is_active == True).all()
return [
{
"id": user.id,
"username": user.username,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"birthday": user.birthday,
"is_active": user.is_active,
}
for user in users
]
@router.get("/chores")
async def get_public_chores(
user_id: Optional[int] = None,
exclude_birthdays: bool = False,
db: Session = Depends(get_db)
):
"""
Get chores with optional filtering (public endpoint for kiosk).
- user_id: Filter by assigned user
- exclude_birthdays: Hide chores for users whose birthday is today
"""
query = db.query(Chore)
# Filter by user if specified
if user_id:
query = query.join(ChoreAssignment).filter(
ChoreAssignment.user_id == user_id
)
chores = query.all()
# Filter out birthday chores if requested
if exclude_birthdays:
today = date.today()
filtered_chores = []
for chore in chores:
# Get all assignments for this chore
assignments = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore.id
).all()
# Check if any assigned user has birthday today
has_birthday = False
for assignment in assignments:
user = db.query(User).filter(User.id == assignment.user_id).first()
if user and user.birthday:
if user.birthday.month == today.month and user.birthday.day == today.day:
has_birthday = True
break
if not has_birthday:
filtered_chores.append(chore)
chores = filtered_chores
# Build response with assigned users info
result = []
for chore in chores:
# Get assignments for this chore
assignments = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore.id
).all()
assigned_users = []
for assignment in assignments:
user = db.query(User).filter(User.id == assignment.user_id).first()
if user:
assigned_users.append({
"id": user.id,
"username": user.username,
"full_name": user.full_name,
"avatar_url": user.avatar_url,
"birthday": user.birthday,
"completed_at": assignment.completed_at
})
chore_dict = {
"id": chore.id,
"title": chore.title,
"description": chore.description,
"room": chore.room,
"frequency": chore.frequency,
"points": chore.points,
"image_url": chore.image_url,
"assignment_type": chore.assignment_type,
"status": chore.status,
"due_date": chore.due_date,
"created_at": chore.created_at,
"updated_at": chore.updated_at,
"completed_at": chore.completed_at,
"assigned_users": assigned_users,
"assigned_user_id": None # Legacy field
}
result.append(chore_dict)
return result
@router.post("/chores/{chore_id}/complete")
async def complete_public_chore(
chore_id: int,
user_id: int,
helper_ids: Optional[List[int]] = None,
db: Session = Depends(get_db)
):
"""
Mark a chore as complete for a specific user (public endpoint for kiosk).
Query params:
- user_id: The user completing the chore
- helper_ids: Optional list of other user IDs who helped
"""
# Get chore
chore = db.query(Chore).filter(Chore.id == chore_id).first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found"
)
# Get user
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Get assignment
assignment = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id,
ChoreAssignment.user_id == user_id
).first()
if not assignment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not assigned to this chore"
)
# Mark as complete
from datetime import datetime
completion_time = datetime.now()
assignment.completed_at = completion_time
# CREATE COMPLETION LOG ENTRY
completion_log = ChoreCompletionLog(
chore_id=chore_id,
user_id=user_id,
completed_at=completion_time,
notes=None # Kiosk doesn't support notes yet
)
db.add(completion_log)
# If helpers provided, mark them as completed too
if helper_ids:
for helper_id in helper_ids:
helper_assignment = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id,
ChoreAssignment.user_id == helper_id
).first()
# Create assignment if doesn't exist (helper claimed they helped)
if not helper_assignment:
helper_assignment = ChoreAssignment(
chore_id=chore_id,
user_id=helper_id,
completed_at=datetime.now()
)
db.add(helper_assignment)
else:
helper_assignment.completed_at = datetime.now()
# CREATE COMPLETION LOG FOR HELPER
helper_log = ChoreCompletionLog(
chore_id=chore_id,
user_id=helper_id,
completed_at=datetime.now(),
notes=f"Helped {user.full_name or user.username}"
)
db.add(helper_log)
# Check if chore is complete based on assignment_type
all_assignments = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id
).all()
if chore.assignment_type == "any_one":
# Only one person needs to complete
if assignment.completed_at:
chore.status = 'completed'
chore.completed_at = datetime.now()
else: # all_assigned
# All assigned must complete
all_complete = all([a.completed_at is not None for a in all_assignments])
if all_complete:
chore.status = 'completed'
chore.completed_at = datetime.now()
else:
chore.status = 'in_progress'
db.commit()
return {
"message": "Chore marked as complete",
"chore_id": chore_id,
"user_id": user_id,
"helper_ids": helper_ids or [],
"chore_status": chore.status
}
@router.post("/chores/{chore_id}/claim")
async def claim_public_chore(
chore_id: int,
user_id: int,
db: Session = Depends(get_db)
):
"""
Claim an available chore (add user as assigned).
Query params:
- user_id: The user claiming the chore
"""
# Get chore
chore = db.query(Chore).filter(Chore.id == chore_id).first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found"
)
# Get user
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check if already assigned
existing = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id,
ChoreAssignment.user_id == user_id
).first()
if existing:
return {
"message": "Already assigned to this chore",
"chore_id": chore_id,
"user_id": user_id
}
# Create assignment
assignment = ChoreAssignment(
chore_id=chore_id,
user_id=user_id
)
db.add(assignment)
# Update chore status if needed
if chore.status == 'pending':
chore.status = 'in_progress'
db.commit()
return {
"message": "Chore claimed successfully",
"chore_id": chore_id,
"user_id": user_id
}

View File

@@ -0,0 +1,322 @@
"""Upload API endpoints for images."""
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File
from sqlalchemy.orm import Session
from pathlib import Path
import uuid
import shutil
from app.core.database import get_db
from app.api.v1.auth import get_current_user
from app.models.user import User
from app.models.chore import Chore
router = APIRouter()
# Configure upload directories
UPLOAD_DIR = Path(__file__).parent.parent.parent / "static" / "uploads"
USER_UPLOAD_DIR = UPLOAD_DIR / "users"
CHORE_UPLOAD_DIR = UPLOAD_DIR / "chores"
# Ensure directories exist
USER_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
CHORE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# Allowed image extensions
ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
def validate_image(filename: str) -> bool:
"""Check if file extension is allowed"""
ext = Path(filename).suffix.lower()
return ext in ALLOWED_EXTENSIONS
def save_upload_file(upload_file: UploadFile, destination: Path) -> str:
"""Save uploaded file and return filename"""
try:
with destination.open("wb") as buffer:
shutil.copyfileobj(upload_file.file, buffer)
return destination.name
finally:
upload_file.file.close()
@router.post("/users/avatar", status_code=status.HTTP_200_OK)
async def upload_user_avatar(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Upload avatar for current user.
- Accepts: JPG, JPEG, PNG, GIF, WEBP
- Max size: 5MB (handled by FastAPI)
- Overwrites existing avatar
"""
if not validate_image(file.filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
)
# Generate unique filename
file_ext = Path(file.filename).suffix.lower()
filename = f"user_{current_user.id}_{uuid.uuid4().hex[:8]}{file_ext}"
file_path = USER_UPLOAD_DIR / filename
# Delete old avatar if exists
if current_user.avatar_url:
old_file = USER_UPLOAD_DIR / Path(current_user.avatar_url).name
if old_file.exists():
old_file.unlink()
# Save new avatar
save_upload_file(file, file_path)
# Update user record
avatar_url = f"/static/uploads/users/{filename}"
current_user.avatar_url = avatar_url
db.commit()
db.refresh(current_user)
return {
"message": "Avatar uploaded successfully",
"avatar_url": avatar_url
}
@router.delete("/users/avatar", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user_avatar(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete current user's avatar"""
if not current_user.avatar_url:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No avatar to delete"
)
# Delete file
old_file = USER_UPLOAD_DIR / Path(current_user.avatar_url).name
if old_file.exists():
old_file.unlink()
# Update database
current_user.avatar_url = None
db.commit()
return None
@router.post("/chores/{chore_id}/image", status_code=status.HTTP_200_OK)
async def upload_chore_image(
chore_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Upload image for a chore (admin or assigned user only).
- Accepts: JPG, JPEG, PNG, GIF, WEBP
- Max size: 5MB (handled by FastAPI)
- Overwrites existing image
"""
# Get chore
chore = db.query(Chore).filter(Chore.id == chore_id).first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found"
)
# Check permissions (admin or assigned user)
from app.models.chore_assignment import ChoreAssignment
is_assigned = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id,
ChoreAssignment.user_id == current_user.id
).first() is not None
if not current_user.is_admin and not is_assigned:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to upload image for this chore"
)
if not validate_image(file.filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
)
# Generate unique filename
file_ext = Path(file.filename).suffix.lower()
filename = f"chore_{chore_id}_{uuid.uuid4().hex[:8]}{file_ext}"
file_path = CHORE_UPLOAD_DIR / filename
# Delete old image if exists
if chore.image_url:
old_file = CHORE_UPLOAD_DIR / Path(chore.image_url).name
if old_file.exists():
old_file.unlink()
# Save new image
save_upload_file(file, file_path)
# Update chore record
image_url = f"/static/uploads/chores/{filename}"
chore.image_url = image_url
db.commit()
db.refresh(chore)
return {
"message": "Image uploaded successfully",
"image_url": image_url
}
@router.delete("/chores/{chore_id}/image", status_code=status.HTTP_204_NO_CONTENT)
async def delete_chore_image(
chore_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete chore image (admin or assigned user only)"""
# Get chore
chore = db.query(Chore).filter(Chore.id == chore_id).first()
if not chore:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Chore not found"
)
# Check permissions
from app.models.chore_assignment import ChoreAssignment
is_assigned = db.query(ChoreAssignment).filter(
ChoreAssignment.chore_id == chore_id,
ChoreAssignment.user_id == current_user.id
).first() is not None
if not current_user.is_admin and not is_assigned:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete image for this chore"
)
if not chore.image_url:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No image to delete"
)
# Delete file
old_file = CHORE_UPLOAD_DIR / Path(chore.image_url).name
if old_file.exists():
old_file.unlink()
# Update database
chore.image_url = None
db.commit()
return None
@router.post("/admin/users/{user_id}/avatar", status_code=status.HTTP_200_OK)
async def admin_upload_user_avatar(
user_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
Upload avatar for any user (admin only).
- Accepts: JPG, JPEG, PNG, GIF, WEBP
- Max size: 5MB
- Admin only
"""
# Check if current user is admin
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can upload avatars for other users"
)
# Get target user
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not validate_image(file.filename):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
)
# Generate unique filename
file_ext = Path(file.filename).suffix.lower()
filename = f"user_{user_id}_{uuid.uuid4().hex[:8]}{file_ext}"
file_path = USER_UPLOAD_DIR / filename
# Delete old avatar if exists
if target_user.avatar_url:
old_file = USER_UPLOAD_DIR / Path(target_user.avatar_url).name
if old_file.exists():
old_file.unlink()
# Save new avatar
save_upload_file(file, file_path)
# Update user record
avatar_url = f"/static/uploads/users/{filename}"
target_user.avatar_url = avatar_url
db.commit()
db.refresh(target_user)
return {
"message": "Avatar uploaded successfully",
"avatar_url": avatar_url
}
@router.delete("/admin/users/{user_id}/avatar", status_code=status.HTTP_204_NO_CONTENT)
async def admin_delete_user_avatar(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete avatar for any user (admin only)"""
# Check if current user is admin
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can delete avatars for other users"
)
# Get target user
target_user = db.query(User).filter(User.id == user_id).first()
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
if not target_user.avatar_url:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No avatar to delete"
)
# Delete file
old_file = USER_UPLOAD_DIR / Path(target_user.avatar_url).name
if old_file.exists():
old_file.unlink()
# Update database
target_user.avatar_url = None
db.commit()
return None

114
backend/app/api/v1/users.py Normal file
View File

@@ -0,0 +1,114 @@
"""User management endpoints."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from app.core.database import get_db
from app.models.user import User
from app.schemas.user import UserResponse, UserUpdate
from app.api.v1.auth import get_current_user
router = APIRouter()
@router.get("/", response_model=List[UserResponse])
async def list_users(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""List all users (admin only)."""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get user by ID."""
# Users can view their own profile, admins can view any profile
if user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
@router.put("/{user_id}", response_model=UserResponse)
async def update_user(
user_id: int,
user_update: UserUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Update user information."""
# Users can update their own profile, admins can update any profile
if user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Update fields if provided
if user_update.email is not None:
user.email = user_update.email
if user_update.full_name is not None:
user.full_name = user_update.full_name
if user_update.password is not None:
from app.core.security import get_password_hash
user.hashed_password = get_password_hash(user_update.password)
if user_update.is_active is not None and current_user.is_admin:
user.is_active = user_update.is_active
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Delete user (admin only)."""
if not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
db.delete(user)
db.commit()
return None