Add account deletion confirmation page, privacy settings template, and profile view template

- Created a new HTML template for account deletion confirmation with user-friendly messaging and a return link.
- Developed a privacy settings page allowing users to control visibility of their personal information with appropriate messaging for success and error states.
- Implemented a profile view template displaying user information, statistics, teams, and achievements based on user permissions.
- Added responsive design elements and improved user interface with Tailwind CSS classes for better aesthetics and usability.
This commit is contained in:
Christian Krakau-Louis
2025-04-16 21:45:33 +02:00
parent 9815a1a3e0
commit 6c260c5936
16 changed files with 1169 additions and 145 deletions
+387 -3
View File
@@ -1,11 +1,13 @@
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks, UploadFile, File
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.templating import Jinja2Templates
from typing import Optional, List, Dict, Any
import secrets
import os
import uuid
import re
import shutil
from pathlib import Path
from starlette.status import HTTP_303_SEE_OTHER, HTTP_302_FOUND
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
@@ -445,7 +447,10 @@ async def oauth_callback(
user.first_name = first_name
if last_name and not user.last_name:
user.last_name = last_name
if picture and not user.picture:
# Only update profile picture if one doesn't exist yet or if it was never manually deleted
# We track manual deletion by setting a flag in the database
if picture and (user.picture is None and not user.picture_manually_deleted):
user.picture = picture
# Update last login time
@@ -529,6 +534,172 @@ async def profile_page(request: Request, db: Session = Depends(get_db)):
{"request": request, "user": user}
)
@router.post("/update-profile-picture", response_class=HTMLResponse)
async def update_profile_picture(
request: Request,
file: UploadFile = File(...),
db: Session = Depends(get_db)
):
"""Handle profile picture upload"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Get user from database
user = db.query(User).filter(User.id == user_id).first()
if not user:
request.session.clear()
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Validate file type
valid_extensions = [".jpg", ".jpeg", ".png"]
file_ext = os.path.splitext(file.filename)[1].lower()
if file_ext not in valid_extensions:
return templates.TemplateResponse(
"auth/profile.html",
{"request": request, "user": user, "error": "Invalid file type. Only JPG and PNG are allowed."}
)
# Create directory if it doesn't exist
upload_dir = Path("app/static/uploads/profile_pictures")
upload_dir.mkdir(parents=True, exist_ok=True)
# Generate unique filename
unique_filename = f"{uuid.uuid4()}{file_ext}"
file_path = upload_dir / unique_filename
# Save the file
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Update user profile with the picture URL
user.picture = f"/static/uploads/profile_pictures/{unique_filename}"
user.picture_manually_deleted = False # Reset manual deletion flag
db.commit()
# Redirect back to profile with success message
return RedirectResponse(
"/auth/profile?message=Profile+picture+updated+successfully",
status_code=HTTP_303_SEE_OTHER
)
@router.post("/delete-profile-picture", response_class=HTMLResponse)
async def delete_profile_picture(
request: Request,
db: Session = Depends(get_db)
):
"""Handle profile picture deletion"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Get user from database
user = db.query(User).filter(User.id == user_id).first()
if not user:
request.session.clear()
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Only proceed if user has a profile picture
if user.picture:
# Get the file path
image_path = user.picture.replace("/static/", "app/static/")
# Try to delete the file if it exists
try:
if os.path.exists(image_path):
os.remove(image_path)
except Exception as e:
print(f"Error deleting profile picture file: {str(e)}")
# Continue anyway since we still want to clear the database entry
# Clear the picture field in the database and set the manually deleted flag
user.picture = None
user.picture_manually_deleted = True
db.commit()
# Redirect back to profile with success message
return RedirectResponse(
"/auth/profile?message=Profile+picture+deleted+successfully",
status_code=HTTP_303_SEE_OTHER
)
@router.post("/update-username", response_class=HTMLResponse)
async def update_username(
request: Request,
username: str = Form(...),
db: Session = Depends(get_db)
):
"""Handle username update"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Get user from database
user = db.query(User).filter(User.id == user_id).first()
if not user:
request.session.clear()
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Check if username is unchanged
if user.username == username:
return RedirectResponse(
"/auth/profile?message=No+changes+made+to+username",
status_code=HTTP_303_SEE_OTHER
)
# Validate username
if len(username) < 3:
return templates.TemplateResponse(
"auth/profile.html",
{"request": request, "user": user, "error": "Username must be at least 3 characters long"}
)
if len(username) > 30:
return templates.TemplateResponse(
"auth/profile.html",
{"request": request, "user": user, "error": "Username must be less than 30 characters long"}
)
# Check if username contains only allowed characters (alphanumeric, underscore, hyphen)
if not re.match(r'^[a-zA-Z0-9_-]+$', username):
return templates.TemplateResponse(
"auth/profile.html",
{"request": request, "user": user, "error": "Username can only contain letters, numbers, underscores and hyphens"}
)
# Check if username already exists
existing_user = db.query(User).filter(User.username == username).first()
if existing_user:
return templates.TemplateResponse(
"auth/profile.html",
{"request": request, "user": user, "error": "Username already taken"}
)
# Update user's username
old_username = user.username
user.username = username
# Update session with new username
request.session["username"] = username
try:
db.commit()
return RedirectResponse(
"/auth/profile?message=Username+updated+successfully",
status_code=HTTP_303_SEE_OTHER
)
except Exception as e:
db.rollback()
print(f"Error updating username: {str(e)}")
return templates.TemplateResponse(
"auth/profile.html",
{"request": request, "user": user, "error": "An error occurred while updating your username"}
)
@router.get("/change-password", response_class=HTMLResponse)
async def change_password_page(request: Request, error: Optional[str] = None, message: Optional[str] = None):
"""Change password page"""
@@ -743,6 +914,219 @@ async def reset_password_post(
status_code=HTTP_303_SEE_OTHER
)
@router.post("/delete-account", response_class=HTMLResponse)
async def delete_account(
request: Request,
db: Session = Depends(get_db)
):
"""Handle account deletion"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Get user from database
user = db.query(User).filter(User.id == user_id).first()
if not user:
request.session.clear()
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Don't allow admins to delete their accounts through this flow
# to prevent accidentally removing the only admin account
if user.is_admin:
return templates.TemplateResponse(
"auth/profile.html",
{"request": request, "user": user, "error": "Admin accounts cannot be deleted through this page. Please contact the system administrator."}
)
try:
# Handle team memberships (anonymize rather than delete)
from ..models import TeamMembership, TeamJoinRequest, UserPoints, EventAttendee
# Get all team memberships
memberships = db.query(TeamMembership).filter(TeamMembership.user_id == user_id).all()
# Clean up any pending join requests
db.query(TeamJoinRequest).filter(TeamJoinRequest.user_id == user_id).delete()
# Instead of deleting data completely, we'll anonymize it to keep integrity
# Update the username and email to indicate this is a deleted account
anonymous_username = f"deleted_user_{user_id}"
anonymous_email = f"deleted_{user_id}@deleted.user"
user.username = anonymous_username
user.email = anonymous_email
user.is_active = False
user.hashed_password = None
user.picture = None
user.first_name = None
user.last_name = None
user.oauth_id = None
user.oauth_provider = None
user.additional_oauth_providers = None
# Mark account as deactivated
db.commit()
# Clear session
request.session.clear()
# Show success page
return templates.TemplateResponse(
"auth/account_deleted.html",
{"request": request}
)
except Exception as e:
db.rollback()
print(f"Error deleting account: {str(e)}")
return templates.TemplateResponse(
"auth/profile.html",
{"request": request, "user": user, "error": "An error occurred while deleting your account. Please try again later."}
)
@router.get("/privacy-settings", response_class=HTMLResponse)
async def privacy_settings_page(request: Request, db: Session = Depends(get_db)):
"""Display privacy settings page"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Get user from database
user = db.query(User).filter(User.id == user_id).first()
if not user:
request.session.clear()
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Get current privacy settings
privacy_settings = user.get_privacy_settings()
return templates.TemplateResponse(
"auth/privacy_settings.html",
{
"request": request,
"user": user,
"privacy_settings": privacy_settings,
"privacy_options": [
{"value": "public", "label": "Everyone", "description": "Visible to all users"},
{"value": "friends", "label": "Team Members", "description": "Only visible to members of your teams"},
{"value": "private", "label": "Private", "description": "Only visible to you and admins"}
]
}
)
@router.post("/privacy-settings", response_class=HTMLResponse)
async def update_privacy_settings(
request: Request,
email_visibility: str = Form(...),
full_name_visibility: str = Form(...),
teams_visibility: str = Form(...),
points_visibility: str = Form(...),
achievements_visibility: str = Form(...),
events_visibility: str = Form(...),
db: Session = Depends(get_db)
):
"""Handle privacy settings update"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Get user from database
user = db.query(User).filter(User.id == user_id).first()
if not user:
request.session.clear()
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Validate inputs
valid_options = ["public", "friends", "private"]
privacy_settings = {
"email": email_visibility if email_visibility in valid_options else "private",
"full_name": full_name_visibility if full_name_visibility in valid_options else "friends",
"teams": teams_visibility if teams_visibility in valid_options else "public",
"points": points_visibility if points_visibility in valid_options else "public",
"achievements": achievements_visibility if achievements_visibility in valid_options else "public",
"events": events_visibility if events_visibility in valid_options else "friends"
}
# Update user privacy settings
user.privacy_settings = privacy_settings
db.commit()
# Redirect back to privacy settings with success message
return RedirectResponse(
"/auth/privacy-settings?message=Privacy+settings+updated+successfully",
status_code=HTTP_303_SEE_OTHER
)
@router.get("/user/{user_id}", response_class=HTMLResponse)
async def view_user_profile(
request: Request,
user_id: int,
db: Session = Depends(get_db)
):
"""View another user's profile with privacy settings applied"""
# Check if the requested user exists
profile_user = db.query(User).filter(User.id == user_id).first()
if not profile_user:
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "User not found"}
)
# Get current logged-in user (if any)
current_user_id = request.session.get("user_id")
current_user = None
if current_user_id:
current_user = db.query(User).filter(User.id == current_user_id).first()
# Check if the user is viewing their own profile
if current_user_id and current_user_id == user_id:
return RedirectResponse("/auth/profile", status_code=HTTP_303_SEE_OTHER)
# Import privacy utilities
from ..utils.auth import get_viewable_profile_data, check_privacy_permission
# Get viewable profile data based on privacy settings
profile_data = get_viewable_profile_data(db, profile_user, current_user_id)
# If the user can view teams, fetch team data
teams = []
if profile_data["can_view_teams"]:
from ..models import TeamMembership, Team
team_memberships = db.query(TeamMembership, Team).join(
Team, TeamMembership.team_id == Team.id
).filter(
TeamMembership.user_id == user_id
).all()
teams = [
{
"id": team.id,
"name": team.name,
"is_captain": membership.is_captain
} for membership, team in team_memberships
]
# If the user can view points, fetch points data
total_points = 0
if profile_data["can_view_points"]:
from ..models import UserPoints
points_records = db.query(UserPoints).filter(UserPoints.user_id == user_id).all()
total_points = sum(record.points for record in points_records)
return templates.TemplateResponse(
"auth/view_profile.html",
{
"request": request,
"profile": profile_data,
"profile_user_id": user_id,
"user": current_user, # Pass the current user for menu display
"teams": teams,
"total_points": total_points,
}
)
def validate_password_strength(password: str) -> Optional[str]:
"""
Validates password strength based on the following criteria: