elmeg-demo/backend/routers/users.py
fullsizemalt 2da46eaa16
Some checks are pending
Deploy Elmeg / deploy (push) Waiting to run
feat: Privacy settings with functional toggles, sticky sidebar, roadmap doc
2025-12-23 13:08:48 -08:00

281 lines
10 KiB
Python

from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlmodel import Session, select, func
from pydantic import BaseModel
from database import get_session
from models import User, Review, Attendance, Group, GroupMember, Show, UserPreferences, Profile
from schemas import UserRead, ReviewRead, ShowRead, GroupRead, UserPreferencesUpdate
from auth import get_current_user
router = APIRouter(prefix="/users", tags=["users"])
class UserProfileUpdate(BaseModel):
bio: Optional[str] = None
avatar: Optional[str] = None
username: Optional[str] = None
display_name: Optional[str] = None
avatar_bg_color: Optional[str] = None
avatar_text: Optional[str] = None
# Preset avatar colors - Jewel Tones (Primary Set)
AVATAR_COLORS = [
"#0F4C81", # Sapphire
"#9B111E", # Ruby
"#50C878", # Emerald
"#9966CC", # Amethyst
"#0D98BA", # Topaz
"#E0115F", # Rose Quartz
"#082567", # Lapis
"#FF7518", # Carnelian
"#006B3C", # Jade
"#1C1C1C", # Onyx
"#E6E200", # Citrine
"#702963", # Garnet
]
class AvatarUpdate(BaseModel):
bg_color: Optional[str] = None
text: Optional[str] = None # 1-3 alphanumeric chars
# Note: Dynamic routes like /{user_id} are placed at the END of this file
# to avoid conflicts with static routes like /me and /avatar
@router.patch("/me", response_model=UserRead)
def update_my_profile(
update: UserProfileUpdate,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Update current user's bio, avatar, and primary profile"""
if update.bio is not None:
current_user.bio = update.bio
if update.avatar is not None:
current_user.avatar = update.avatar
if update.avatar_bg_color is not None:
# Validate it's a valid hex color or preset
if update.avatar_bg_color in AVATAR_COLORS or (update.avatar_bg_color.startswith('#') and len(update.avatar_bg_color) == 7):
current_user.avatar_bg_color = update.avatar_bg_color
if update.avatar_text is not None:
# Validate 1-3 alphanumeric characters
import re
if len(update.avatar_text) <= 3 and re.match(r'^[A-Za-z0-9]*$', update.avatar_text):
current_user.avatar_text = update.avatar_text if update.avatar_text else None
if update.username or update.display_name:
# Find or create primary profile
query = select(Profile).where(Profile.user_id == current_user.id)
profile = session.exec(query).first()
if not profile:
if not update.username:
raise HTTPException(status_code=400, detail="Username required for new profile")
# Check uniqueness (naive check)
existing = session.exec(select(Profile).where(Profile.username == update.username)).first()
if existing:
raise HTTPException(status_code=400, detail="Username taken")
profile = Profile(
user_id=current_user.id,
username=update.username,
display_name=update.display_name or update.username
)
session.add(profile)
else:
if update.username:
# Check uniqueness if changing
if update.username != profile.username:
existing = session.exec(select(Profile).where(Profile.username == update.username)).first()
if existing:
raise HTTPException(status_code=400, detail="Username taken")
profile.username = update.username
if update.display_name:
profile.display_name = update.display_name
session.add(profile)
session.add(current_user)
session.commit()
session.refresh(current_user)
return current_user
@router.patch("/me/avatar")
def update_avatar(
update: AvatarUpdate,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Update avatar customization"""
import re
if update.bg_color is not None:
if update.bg_color in AVATAR_COLORS or (update.bg_color.startswith('#') and len(update.bg_color) == 7):
current_user.avatar_bg_color = update.bg_color
else:
raise HTTPException(status_code=400, detail="Invalid color. Use a preset color or valid hex.")
if update.text is not None:
if len(update.text) > 3:
raise HTTPException(status_code=400, detail="Avatar text must be 3 characters or less")
if update.text and not re.match(r'^[A-Za-z0-9]*$', update.text):
raise HTTPException(status_code=400, detail="Avatar text must be alphanumeric")
current_user.avatar_text = update.text.upper() if update.text else None
session.add(current_user)
session.commit()
session.refresh(current_user)
return {
"avatar_bg_color": current_user.avatar_bg_color,
"avatar_text": current_user.avatar_text
}
@router.get("/avatar/colors")
def get_avatar_colors():
"""Get available avatar preset colors"""
return {"colors": AVATAR_COLORS}
class PrivacyUpdate(BaseModel):
profile_public: Optional[bool] = None
show_attendance_public: Optional[bool] = None
appear_in_leaderboards: Optional[bool] = None
@router.patch("/me/privacy")
def update_privacy(
update: PrivacyUpdate,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
"""Update privacy settings"""
if update.profile_public is not None:
current_user.profile_public = update.profile_public
if update.show_attendance_public is not None:
current_user.show_attendance_public = update.show_attendance_public
if update.appear_in_leaderboards is not None:
current_user.appear_in_leaderboards = update.appear_in_leaderboards
session.add(current_user)
session.commit()
session.refresh(current_user)
return {
"profile_public": current_user.profile_public,
"show_attendance_public": current_user.show_attendance_public,
"appear_in_leaderboards": current_user.appear_in_leaderboards
}
@router.patch("/me/preferences", response_model=UserPreferencesUpdate)
def update_preferences(
prefs: UserPreferencesUpdate,
current_user: User = Depends(get_current_user),
session: Session = Depends(get_session)
):
# Find or create
if not current_user.preferences:
# Need to create
db_prefs = UserPreferences(user_id=current_user.id)
current_user.preferences = db_prefs # Link it?
# Actually, if relation is set up, adding to session should work.
# But safest to create explicitly.
db_prefs = UserPreferences(
user_id=current_user.id,
wiki_mode=prefs.wiki_mode if prefs.wiki_mode is not None else False,
show_ratings=prefs.show_ratings if prefs.show_ratings is not None else True,
show_comments=prefs.show_comments if prefs.show_comments is not None else True
)
session.add(db_prefs)
else:
db_prefs = current_user.preferences
if prefs.wiki_mode is not None:
db_prefs.wiki_mode = prefs.wiki_mode
if prefs.show_ratings is not None:
db_prefs.show_ratings = prefs.show_ratings
if prefs.show_comments is not None:
db_prefs.show_comments = prefs.show_comments
session.add(db_prefs)
session.commit()
session.refresh(db_prefs)
return db_prefs
# --- User Stats ---
@router.get("/{user_id}/stats")
def get_user_stats(user_id: int, session: Session = Depends(get_session)):
# Check if user exists
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
attendance_count = session.exec(select(func.count(Attendance.id)).where(Attendance.user_id == user_id)).one()
review_count = session.exec(select(func.count(Review.id)).where(Review.user_id == user_id)).one()
group_count = session.exec(select(func.count(GroupMember.id)).where(GroupMember.user_id == user_id)).one()
return {
"attendance_count": attendance_count,
"review_count": review_count,
"group_count": group_count
}
# --- User Data Lists ---
@router.get("/{user_id}/attendance", response_model=List[ShowRead])
def get_user_attendance(
user_id: int,
offset: int = 0,
limit: int = Query(default=50, le=100),
session: Session = Depends(get_session)
):
# Return shows the user attended
shows = session.exec(
select(Show)
.join(Attendance, Show.id == Attendance.show_id)
.where(Attendance.user_id == user_id)
.order_by(Show.date.desc())
.offset(offset)
.limit(limit)
).all()
return shows
@router.get("/{user_id}/reviews", response_model=List[ReviewRead])
def get_user_reviews(
user_id: int,
offset: int = 0,
limit: int = Query(default=50, le=100),
session: Session = Depends(get_session)
):
reviews = session.exec(
select(Review)
.where(Review.user_id == user_id)
.order_by(Review.created_at.desc())
.offset(offset)
.limit(limit)
).all()
return reviews
@router.get("/{user_id}/groups", response_model=List[GroupRead])
def get_user_groups(
user_id: int,
offset: int = 0,
limit: int = Query(default=50, le=100),
session: Session = Depends(get_session)
):
groups = session.exec(
select(Group)
.join(GroupMember, Group.id == GroupMember.group_id)
.where(GroupMember.user_id == user_id)
.offset(offset)
.limit(limit)
).all()
return groups
# --- Dynamic ID Routes (must be last to avoid conflicts with /me, /avatar) ---
@router.get("/{user_id}", response_model=UserRead)
def get_user_public(user_id: int, session: Session = Depends(get_session)):
"""Get public user profile"""
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user