first prototype commit

This commit is contained in:
2026-08-09 21:45:06 -04:00
commit 40613a00d5
36 changed files with 4371 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
from fastapi import APIRouter, Depends, HTTPException, status, Header
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import User, UserRole
from backend.schemas import UserOut, GoogleAuthRequest, DemoLoginRequest
from backend.config import create_access_token, decode_access_token, GOOGLE_CLIENT_ID
from typing import Optional
from google.oauth2 import id_token
from google.auth.transport import requests as google_requests
router = APIRouter(prefix="/api/auth", tags=["auth"])
def get_current_user(authorization: Optional[str] = Header(None), db: Session = Depends(get_db)) -> User:
if not authorization:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing authorization header")
parts = authorization.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authorization header format")
token = parts[1]
payload = decode_access_token(token)
if not payload or "sub" not in payload:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token")
user_id = int(payload["sub"])
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
return user
def get_admin_user(current_user: User = Depends(get_current_user)) -> User:
if current_user.role != UserRole.ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required")
return current_user
@router.post("/google")
def google_auth(req: GoogleAuthRequest, db: Session = Depends(get_db)):
email = req.email
name = req.name or "Google User"
avatar_url = req.avatar_url or f"https://api.dicebear.com/7.x/bottts/svg?seed={email}"
google_sub = None
# Try verifying real Google token if client ID configured and credential provided
if req.credential and len(req.credential) > 50:
try:
if GOOGLE_CLIENT_ID:
id_info = id_token.verify_oauth2_token(req.credential, google_requests.Request(), GOOGLE_CLIENT_ID)
else:
id_info = id_token.verify_oauth2_token(req.credential, google_requests.Request())
email = id_info.get("email", email)
name = id_info.get("name", name)
avatar_url = id_info.get("picture", avatar_url)
google_sub = id_info.get("sub")
except Exception as e:
# Fallback to provided details if token decode fails in dev
if not email:
raise HTTPException(status_code=400, detail=f"Google token verification failed: {str(e)}")
if not email:
raise HTTPException(status_code=400, detail="Email is required")
user = db.query(User).filter(User.email == email).first()
# Check if this is the first user ever registered
total_users = db.query(User).count()
initial_role = UserRole.ADMIN.value if total_users == 0 else UserRole.REGULAR.value
if not user:
user = User(
email=email,
name=name,
avatar_url=avatar_url,
google_id=google_sub,
role=initial_role,
weekly_star_quota=15
)
db.add(user)
db.commit()
db.refresh(user)
else:
# Update existing user google_id or default avatar if missing
if google_sub and not user.google_id:
user.google_id = google_sub
if avatar_url and not user.avatar_url:
user.avatar_url = avatar_url
db.commit()
db.refresh(user)
token = create_access_token({"sub": str(user.id), "email": user.email, "role": user.role})
return {
"access_token": token,
"token_type": "bearer",
"user": UserOut.model_validate(user)
}
@router.post("/demo-login")
def demo_login(req: DemoLoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == req.email).first()
if not user:
raise HTTPException(status_code=404, detail="Demo user not found. Run backend initialization first.")
token = create_access_token({"sub": str(user.id), "email": user.email, "role": user.role})
return {
"access_token": token,
"token_type": "bearer",
"user": UserOut.model_validate(user)
}
@router.get("/me", response_model=UserOut)
def get_me(current_user: User = Depends(get_current_user)):
return current_user
+132
View File
@@ -0,0 +1,132 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from datetime import datetime, timezone
from backend.database import get_db
from backend.models import User, ChoreType, ChoreCompletion, UserRole, RecurrenceType
from backend.schemas import ChoreTypeOut, ChoreTypeCreate, ChoreTypeUpdate, ChoreCompletionCreate, ChoreCompletionOut
from backend.routers.auth import get_current_user, get_admin_user
from backend.config import get_current_week_identifier
router = APIRouter(prefix="/api/chores", tags=["chores"])
@router.get("/types", response_model=List[ChoreTypeOut])
def list_chore_types(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(ChoreType).order_by(ChoreType.id.asc()).all()
@router.post("/types", response_model=ChoreTypeOut)
def create_chore_type(chore_in: ChoreTypeCreate, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
if chore_in.star_reward < 1 or chore_in.star_reward > 5:
raise HTTPException(status_code=400, detail="Star reward must be between 1 and 5")
if chore_in.recurrence not in [r.value for r in RecurrenceType]:
raise HTTPException(status_code=400, detail=f"Invalid recurrence type. Must be daily, weekly, or spontaneous")
new_chore = ChoreType(
title=chore_in.title,
description=chore_in.description,
star_reward=chore_in.star_reward,
recurrence=chore_in.recurrence,
icon=chore_in.icon
)
db.add(new_chore)
db.commit()
db.refresh(new_chore)
return new_chore
@router.patch("/types/{chore_id}", response_model=ChoreTypeOut)
def update_chore_type(chore_id: int, chore_in: ChoreTypeUpdate, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
chore = db.query(ChoreType).filter(ChoreType.id == chore_id).first()
if not chore:
raise HTTPException(status_code=404, detail="Chore type not found")
if chore_in.title is not None:
chore.title = chore_in.title
if chore_in.description is not None:
chore.description = chore_in.description
if chore_in.star_reward is not None:
if chore_in.star_reward < 1 or chore_in.star_reward > 5:
raise HTTPException(status_code=400, detail="Star reward must be between 1 and 5")
chore.star_reward = chore_in.star_reward
if chore_in.recurrence is not None:
if chore_in.recurrence not in [r.value for r in RecurrenceType]:
raise HTTPException(status_code=400, detail="Invalid recurrence type")
chore.recurrence = chore_in.recurrence
if chore_in.icon is not None:
chore.icon = chore_in.icon
db.commit()
db.refresh(chore)
return chore
@router.delete("/types/{chore_id}")
def delete_chore_type(chore_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
chore = db.query(ChoreType).filter(ChoreType.id == chore_id).first()
if not chore:
raise HTTPException(status_code=404, detail="Chore type not found")
db.delete(chore)
db.commit()
return {"message": "Chore type deleted successfully", "id": chore_id}
@router.post("/complete", response_model=ChoreCompletionOut)
def complete_chore(payload: ChoreCompletionCreate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
chore_type = db.query(ChoreType).filter(ChoreType.id == payload.chore_type_id).first()
if not chore_type:
raise HTTPException(status_code=404, detail="Chore type not found")
week_id = get_current_week_identifier()
now_utc = datetime.now(timezone.utc)
completion = ChoreCompletion(
chore_type_id=chore_type.id,
user_id=current_user.id,
completed_at=now_utc,
stars_earned=chore_type.star_reward,
week_identifier=week_id,
notes=payload.notes
)
db.add(completion)
db.commit()
db.refresh(completion)
return ChoreCompletionOut(
id=completion.id,
chore_type_id=completion.chore_type_id,
user_id=completion.user_id,
completed_at=completion.completed_at,
stars_earned=completion.stars_earned,
week_identifier=completion.week_identifier,
notes=completion.notes,
user_name=current_user.name,
user_avatar=current_user.avatar_url,
chore_title=chore_type.title,
chore_icon=chore_type.icon
)
@router.get("/completions", response_model=List[ChoreCompletionOut])
def get_recent_completions(week: Optional[str] = None, user_id: Optional[int] = None, limit: int = 50, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
query = db.query(ChoreCompletion)
if week:
query = query.filter(ChoreCompletion.week_identifier == week)
if user_id:
query = query.filter(ChoreCompletion.user_id == user_id)
completions = query.order_by(ChoreCompletion.completed_at.desc()).limit(limit).all()
result = []
for c in completions:
result.append(ChoreCompletionOut(
id=c.id,
chore_type_id=c.chore_type_id,
user_id=c.user_id,
completed_at=c.completed_at,
stars_earned=c.stars_earned,
week_identifier=c.week_identifier,
notes=c.notes,
user_name=c.user.name if c.user else "Unknown",
user_avatar=c.user.avatar_url if c.user else None,
chore_title=c.chore_type.title if c.chore_type else "Chore",
chore_icon=c.chore_type.icon if c.chore_type else "CheckSquare"
))
return result
+66
View File
@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from typing import List, Optional
from backend.database import get_db
from backend.models import User, ChoreCompletion, ChoreType
from backend.schemas import WeeklyReportOut, UserWeekProgress
from backend.routers.auth import get_current_user
from backend.config import get_current_week_identifier
router = APIRouter(prefix="/api/reports", tags=["reports"])
@router.get("/weeks")
def get_available_weeks(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
# Returns all distinct week_identifiers present in database plus current week
weeks = db.query(ChoreCompletion.week_identifier).distinct().all()
week_set = {w[0] for w in weeks if w[0]}
current_week = get_current_week_identifier()
week_set.add(current_week)
sorted_weeks = sorted(list(week_set), reverse=True)
return {"current_week": current_week, "weeks": sorted_weeks}
@router.get("/weekly", response_model=WeeklyReportOut)
def get_weekly_report(week: Optional[str] = Query(None), db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
target_week = week if week else get_current_week_identifier()
users = db.query(User).filter(User.is_active == True).order_by(User.id.asc()).all()
user_progress_list: List[UserWeekProgress] = []
total_stars_earned = 0
total_completions = 0
for user in users:
completions = db.query(ChoreCompletion).filter(
ChoreCompletion.user_id == user.id,
ChoreCompletion.week_identifier == target_week
).all()
stars = sum(c.stars_earned for c in completions)
comp_count = len(completions)
total_stars_earned += stars
total_completions += comp_count
quota = user.weekly_star_quota if user.weekly_star_quota > 0 else 1
pct = round(min(100.0, (stars / quota) * 100), 1)
user_progress_list.append(UserWeekProgress(
user_id=user.id,
name=user.name,
email=user.email,
avatar_url=user.avatar_url,
role=user.role,
weekly_star_quota=user.weekly_star_quota,
stars_earned=stars,
percentage=pct,
completions_count=comp_count
))
# Sort users by stars earned descending
user_progress_list.sort(key=lambda u: u.stars_earned, reverse=True)
return WeeklyReportOut(
week_identifier=target_week,
user_progress=user_progress_list,
total_stars_earned=total_stars_earned,
total_completions=total_completions
)
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List, Optional
from backend.database import get_db
from backend.models import User, UserRole
from backend.schemas import UserOut, UserCreate, UserUpdate
from backend.routers.auth import get_current_user, get_admin_user
router = APIRouter(prefix="/api/users", tags=["users"])
@router.get("", response_model=List[UserOut])
def get_all_users(db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
return db.query(User).order_by(User.id.asc()).all()
@router.post("", response_model=UserOut)
def create_user(user_in: UserCreate, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
existing = db.query(User).filter(User.email == user_in.email).first()
if existing:
raise HTTPException(status_code=400, detail="User with this email already exists")
avatar = user_in.avatar_url or f"https://api.dicebear.com/7.x/bottts/svg?seed={user_in.email}"
new_user = User(
email=user_in.email,
name=user_in.name,
google_id=user_in.google_id,
avatar_url=avatar,
role=user_in.role,
weekly_star_quota=user_in.weekly_star_quota
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.get("/{user_id}", response_model=UserOut)
def get_user_by_id(user_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.patch("/{user_id}", response_model=UserOut)
def update_user(user_id: int, user_in: UserUpdate, db: Session = Depends(get_db), current_user: User = Depends(get_current_user)):
# Regular users can only update their own avatar and name. Admins can update role, quota, active status.
if current_user.role != UserRole.ADMIN.value and current_user.id != user_id:
raise HTTPException(status_code=403, detail="Cannot edit another user's account")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user_in.name is not None:
user.name = user_in.name
if user_in.avatar_url is not None:
user.avatar_url = user_in.avatar_url
# Admin-only fields
if current_user.role == UserRole.ADMIN.value:
if user_in.role is not None:
user.role = user_in.role
if user_in.weekly_star_quota is not None:
user.weekly_star_quota = user_in.weekly_star_quota
if user_in.is_active is not None:
user.is_active = user_in.is_active
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db), admin: User = Depends(get_admin_user)):
if admin.id == user_id:
raise HTTPException(status_code=400, detail="Cannot delete your own admin account")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()
return {"message": "User deleted successfully", "id": user_id}