first prototype commit
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user