diff --git a/app/db_init.py b/app/db_init.py index c7bbc39..8fd13f4 100644 --- a/app/db_init.py +++ b/app/db_init.py @@ -71,32 +71,80 @@ def seed_db(): username="admin", email="admin@example.com", hashed_password=get_password_hash("password"), - is_admin=True # Set admin privileges + is_admin=True, # Set admin privileges + privacy_settings={ + "email": "private", + "full_name": "friends", + "teams": "public", + "points": "public", + "achievements": "public", + "events": "friends" + } ), User( username="john_quizmaster", email="john@example.com", - hashed_password=get_password_hash("password123") + hashed_password=get_password_hash("password123"), + privacy_settings={ + "email": "friends", + "full_name": "public", + "teams": "public", + "points": "public", + "achievements": "public", + "events": "public" + } ), User( username="sarah_johnson", email="sarah@example.com", - hashed_password=get_password_hash("password123") + hashed_password=get_password_hash("password123"), + privacy_settings={ + "email": "private", + "full_name": "public", + "teams": "public", + "points": "friends", + "achievements": "public", + "events": "friends" + } ), User( username="mike_peters", email="mike@example.com", - hashed_password=get_password_hash("password123") + hashed_password=get_password_hash("password123"), + privacy_settings={ + "email": "private", + "full_name": "friends", + "teams": "public", + "points": "public", + "achievements": "public", + "events": "public" + } ), User( username="emma_wilson", email="emma@example.com", - hashed_password=get_password_hash("password123") + hashed_password=get_password_hash("password123"), + privacy_settings={ + "email": "private", + "full_name": "private", + "teams": "friends", + "points": "private", + "achievements": "friends", + "events": "private" + } ), User( username="robert_brown", email="robert@example.com", - hashed_password=get_password_hash("password123") + hashed_password=get_password_hash("password123"), + privacy_settings={ + "email": "private", + "full_name": "friends", + "teams": "public", + "points": "public", + "achievements": "public", + "events": "friends" + } ), ] db.add_all(users) diff --git a/app/db_migrations.py b/app/db_migrations.py index 810f14d..75c7495 100644 --- a/app/db_migrations.py +++ b/app/db_migrations.py @@ -110,13 +110,19 @@ def run_migrations(engine): # Add first_name and last_name columns if they don't exist add_name_columns(connection) + # Add privacy_settings column if it doesn't exist + add_privacy_settings_column(connection) + + # Add picture_manually_deleted column if it doesn't exist + add_picture_manually_deleted_column(connection) + print("Migrations completed successfully") except Exception as e: print(f"Error during migrations: {str(e)}") finally: connection.close() - + def add_oauth_providers_column(connection): """Add additional_oauth_providers column to users table""" try: @@ -175,3 +181,61 @@ def add_name_columns(connection): print("Column last_name already exists") except Exception as e: print(f"Error adding name columns: {str(e)}") + +def add_privacy_settings_column(connection): + """Add privacy_settings column to users table""" + try: + # Use database-agnostic way to check if column exists + inspector = inspect(engine) + columns = [col['name'] for col in inspector.get_columns('users')] + + if 'privacy_settings' not in columns: + print("Adding privacy_settings column to users table") + + # Add column with database-specific syntax + if engine.name == 'sqlite': + connection.execute(text(""" + ALTER TABLE users + ADD COLUMN privacy_settings JSON + """)) + else: # MySQL + connection.execute(text(""" + ALTER TABLE users + ADD COLUMN privacy_settings JSON NULL + """)) + + connection.commit() + print("Successfully added privacy_settings column to users table") + else: + print("Column privacy_settings already exists") + except Exception as e: + print(f"Error adding privacy_settings column: {str(e)}") + +def add_picture_manually_deleted_column(connection): + """Add picture_manually_deleted column to users table""" + try: + # Use database-agnostic way to check if column exists + inspector = inspect(engine) + columns = [col['name'] for col in inspector.get_columns('users')] + + if 'picture_manually_deleted' not in columns: + print("Adding picture_manually_deleted column to users table") + + # Add column with database-specific syntax + if engine.name == 'sqlite': + connection.execute(text(""" + ALTER TABLE users + ADD COLUMN picture_manually_deleted BOOLEAN DEFAULT FALSE + """)) + else: # MySQL + connection.execute(text(""" + ALTER TABLE users + ADD COLUMN picture_manually_deleted BOOLEAN DEFAULT FALSE + """)) + + connection.commit() + print("Successfully added picture_manually_deleted column to users table") + else: + print("Column picture_manually_deleted already exists") + except Exception as e: + print(f"Error adding picture_manually_deleted column: {str(e)}") diff --git a/app/models.py b/app/models.py index 79ab5de..69b7091 100644 --- a/app/models.py +++ b/app/models.py @@ -39,6 +39,11 @@ class User(Base, BaseUser): first_name = Column(String(50), nullable=True) last_name = Column(String(50), nullable=True) picture = Column(String(255), nullable=True) # URL to profile picture + picture_manually_deleted = Column(Boolean, default=False) # Track if user has deleted their profile picture + + # Privacy settings - JSON field to store privacy preferences + # Default: { "email": "private", "teams": "public", "points": "public", "achievements": "public" } + privacy_settings = Column(JSON, nullable=True) # Relationships memberships = relationship("TeamMembership", back_populates="user") @@ -61,6 +66,23 @@ class User(Base, BaseUser): def identity(self) -> str: """Return the identity of this user.""" return str(self.id) + + def get_default_privacy_settings(self): + """Return the default privacy settings if none are set""" + return { + "email": "private", + "full_name": "friends", + "teams": "public", + "points": "public", + "achievements": "public", + "events": "friends" + } + + def get_privacy_settings(self): + """Get user's privacy settings or default if not set""" + if not self.privacy_settings: + return self.get_default_privacy_settings() + return self.privacy_settings def __repr__(self): return f"" diff --git a/app/static/uploads/profile_pictures/e9d2a6f4-861e-4da7-986c-9784bf5cb175.png b/app/static/uploads/profile_pictures/e9d2a6f4-861e-4da7-986c-9784bf5cb175.png new file mode 100644 index 0000000..0d627c9 Binary files /dev/null and b/app/static/uploads/profile_pictures/e9d2a6f4-861e-4da7-986c-9784bf5cb175.png differ diff --git a/app/templates/auth/account_deleted.html b/app/templates/auth/account_deleted.html new file mode 100644 index 0000000..de52c0d --- /dev/null +++ b/app/templates/auth/account_deleted.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} +{% block content %} +
+
+
+ +
+ +

Account Deleted

+ +

+ Your account has been successfully deleted. All your personal information has been removed from our system. +

+ +
+

+ We're sorry to see you go. You can always create a new account if you wish to return. +

+ + +
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/privacy_settings.html b/app/templates/auth/privacy_settings.html new file mode 100644 index 0000000..1b58005 --- /dev/null +++ b/app/templates/auth/privacy_settings.html @@ -0,0 +1,152 @@ +{% extends "base.html" %} +{% block content %} +
+
+

Privacy Settings

+ + + {% if request.query_params.message %} +
+ {{ request.query_params.message }} +
+ {% endif %} + + + {% if error %} +
+ {{ error }} +
+ {% endif %} + +

+ Control who can see different parts of your profile information. Your information can be visible to everyone, + only members of your teams, or kept private (visible only to you and admins). +

+ +
+
+ +
+

Email Address

+
+ {% for option in privacy_options %} + + {% endfor %} +
+
+ + +
+

Full Name

+
+ {% for option in privacy_options %} + + {% endfor %} +
+
+ + +
+

Teams Membership

+
+ {% for option in privacy_options %} + + {% endfor %} +
+
+ + +
+

Points & Leaderboard Position

+
+ {% for option in privacy_options %} + + {% endfor %} +
+
+ + +
+

Achievements

+
+ {% for option in privacy_options %} + + {% endfor %} +
+
+ + +
+

Event Attendance

+
+ {% for option in privacy_options %} + + {% endfor %} +
+
+ +
+ + Back to Profile + + +
+
+
+ +
+

Note: Administrators can always view your complete profile information for support purposes.

+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html index 090549c..cb4c1a8 100644 --- a/app/templates/auth/profile.html +++ b/app/templates/auth/profile.html @@ -9,6 +9,13 @@ {% endif %} + + {% if error %} +
+ {{ error }} +
+ {% endif %} +
@@ -19,6 +26,26 @@ {{ user.username[0]|upper }}
{% endif %} + + +
+
+ +
+ + {% if user.picture %} +
+ +
+ {% endif %} + +

JPG/PNG only

+
@@ -41,6 +68,15 @@

Account Settings

+
+

Username

+
+ + +
+

Change your username (must be unique)

+
+

Change Password

Update your password to keep your account secure.

@@ -49,13 +85,22 @@
+
+

Privacy Settings

+

Control who can see your profile information.

+ + Manage Privacy Settings + +
+

Danger Zone

Permanently delete your account and all of your data.

- +
+ +
diff --git a/app/templates/auth/view_profile.html b/app/templates/auth/view_profile.html new file mode 100644 index 0000000..2887f5f --- /dev/null +++ b/app/templates/auth/view_profile.html @@ -0,0 +1,123 @@ +{% extends "base.html" %} +{% block content %} +
+
+ +
+
+
+ {% if profile.picture %} + Profile + {% else %} +
+ {{ profile.username[0]|upper }} +
+ {% endif %} +
+
+

{{ profile.username }}

+

Member since {{ profile.created_at.strftime('%B %Y') }}

+

+ {% if profile.is_admin %} + Admin + {% endif %} +

+
+
+
+ + +
+
+ +
+

Profile Information

+ +
+
+

Username

+

{{ profile.username }}

+
+ + {% if "email" in profile %} +
+

Email

+

{{ profile.email }}

+
+ {% endif %} + + {% if "first_name" in profile and "last_name" in profile %} +
+

Full Name

+

{{ profile.first_name }} {{ profile.last_name }}

+
+ {% elif "first_name" in profile %} +
+

First Name

+

{{ profile.first_name }}

+
+ {% endif %} + +
+

Account Type

+

{% if profile.is_admin %}Administrator{% else %}User{% endif %}

+
+
+
+ + +
+ {% if profile.can_view_points %} +
+

Statistics

+
+
+

Total Points

+

{{ total_points }}

+
+
+
+ {% endif %} + + {% if profile.can_view_teams and teams %} +
+

Teams

+
+ {% for team in teams %} +
+
+
{{ team.name }}
+ {% if team.is_captain %} + Captain + {% else %} + Member + {% endif %} +
+
+ View Team +
+
+ {% endfor %} +
+
+ {% endif %} + + {% if profile.can_view_achievements %} +
+

Achievements

+

Achievements data will be shown here

+
+ {% endif %} + + {% if profile.can_view_events %} +
+

Recent Events

+

Recent events will be shown here

+
+ {% endif %} +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index 4a67cba..e11f1c8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -78,7 +78,7 @@ {% else %} - Sign In + Sign In {% endif %} @@ -116,14 +116,14 @@ Leaderboard Scan QR Code {% if user %} - Profile + Profile Dashboard {% if user.is_admin %} Admin {% endif %} - Logout + Logout {% else %} - Sign In + Sign In {% endif %} @@ -161,9 +161,9 @@

Account

diff --git a/app/templates/dashboard/index.html b/app/templates/dashboard/index.html index 9a3e278..5247187 100644 --- a/app/templates/dashboard/index.html +++ b/app/templates/dashboard/index.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block content %} -
+

Welcome, {{ user.username }}!

diff --git a/app/templates/index.html b/app/templates/index.html index 8ac5f71..ff6b8ef 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block content %} -
+

PubQuiz League Tracker

@@ -13,9 +13,9 @@
-
+

How It Works

-
+
diff --git a/app/templates/profile.html b/app/templates/profile.html index 7f01ded..48e8822 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -6,13 +6,21 @@
- Profile + {% if user.picture %} + Profile + {% else %} +
+ {{ user.username[0]|upper }} +
+ {% endif %}
-

John Quizmaster

-

Member since October 2022

+

{{ user.username }}

+

Member since {{ user.created_at.strftime('%B %Y') }}

- Quiz Master + {% if user.is_admin %} + Admin + {% endif %} Team Captain

@@ -21,45 +29,82 @@
+ + {% if request.query_params.message %} +
+ {{ request.query_params.message }} +
+ {% endif %} + + + {% if error %} +
+ {{ error }} +
+ {% endif %} +

Personal Information

+
-
- - -
+ +

Change your username (must be unique)

+
- - +
+ +
+ +
+
+ + +
+

JPG or PNG formats only

+
+
+ + - -
- + + +
+ + +

Control who can see your information

@@ -132,9 +177,11 @@

The following actions are irreversible. Please proceed with caution.

- +
+ +
diff --git a/app/templates/team_detail.html b/app/templates/team_detail.html index 1cee6e6..fb26523 100644 --- a/app/templates/team_detail.html +++ b/app/templates/team_detail.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block content %} -
+
@@ -88,10 +88,18 @@
- User + {% if member.user.picture %} + User + {% else %} +
+ {{ member.user.username[0]|upper }} +
+ {% endif %}
-

{{ member.user.username }}

+

+ {{ member.user.username }} +

Joined {{ member.joined }}

diff --git a/app/templates/teams.html b/app/templates/teams.html index a6ab628..ad4389c 100644 --- a/app/templates/teams.html +++ b/app/templates/teams.html @@ -1,107 +1,109 @@ {% extends "base.html" %} {% block content %} -

Teams

+
+

Teams

-{% if error %} - -{% endif %} + {% if error %} + + {% endif %} -
-
-

Available Teams

- {% if teams %} -
    - {% for team in teams %} -
  • - {{ team.name }} - {% if user %} - {% if team.id in user_team_ids %} - Member +
    +
    +

    Available Teams

    + {% if teams %} +
      + {% for team in teams %} +
    • + {{ team.name }} + {% if user %} + {% if team.id in user_team_ids %} + Member + {% else %} +
      + +
      + {% endif %} {% else %} -
      - -
      + + Login to Join + {% endif %} - {% else %} - - Login to Join - - {% endif %} -
    • - {% endfor %} -
    - {% else %} -

    No teams available yet.

    - {% endif %} -
    +
  • + {% endfor %} +
+ {% else %} +

No teams available yet.

+ {% endif %} +
-
-

Create New Team

- {% if user %} -
-
- - -
- -
- {% else %} -
-

You need to be logged in to create a team

- - Log In - -
- {% endif %} -
-
- -{% if user and user_team_ids %} -
-

Your Teams

-
- {% for team in teams %} - {% if team.id in user_team_ids %} -
-

{{ team.name }}

+
+

Create New Team

+ {% if user %} +
- {{ team.description|default("No description available", true)|truncate(120) }} + +
- - View Team + + + {% else %} +
{% endif %} - {% endfor %} +
+
+ + {% if user and user_team_ids %} +
+

Your Teams

+
+ {% for team in teams %} + {% if team.id in user_team_ids %} +
+

{{ team.name }}

+
+ {{ team.description|default("No description available", true)|truncate(120) }} +
+ + View Team + +
+ {% endif %} + {% endfor %} +
+
+ {% endif %} + +
+

About Teams

+

+ Teams are the heart of LeagueLedger. Join an existing team or create your own to start tracking your pub quiz triumphs! +

+

+ Every point counts in the journey to becoming pub quiz champions. +

-{% endif %} - -
-

About Teams

-

- Teams are the heart of LeagueLedger. Join an existing team or create your own to start tracking your pub quiz triumphs! -

-

- Every point counts in the journey to becoming pub quiz champions. -

-
{% endblock %} diff --git a/app/utils/auth.py b/app/utils/auth.py index 09e4093..663be35 100644 --- a/app/utils/auth.py +++ b/app/utils/auth.py @@ -3,7 +3,7 @@ Authentication utilities for LeagueLedger. This module provides backward compatibility with the existing code while leveraging the new Starlette authentication system. """ -from typing import Optional +from typing import Optional, Dict, Any from fastapi import Request, Depends from sqlalchemy.orm import Session from ..db import get_db @@ -157,5 +157,106 @@ async def requires_admin(request: Request, db: Session = Depends(get_db)) -> Use return user +def check_privacy_permission( + db: Session, + profile_user: User, + viewing_user_id: Optional[int], + setting_name: str +) -> bool: + """ + Check if the viewing user has permission to see a specific profile setting + + Args: + db: Database session + profile_user: The user whose profile is being viewed + viewing_user_id: The ID of the user viewing the profile (None if not logged in) + setting_name: The name of the setting to check (email, full_name, teams, points, achievements, events) + + Returns: + True if viewer has permission to see the setting, False otherwise + """ + # Admin users can see everything + if viewing_user_id: + viewing_user = db.query(User).filter(User.id == viewing_user_id).first() + if viewing_user and viewing_user.is_admin: + return True + + # Owner can see everything on their own profile + if viewing_user_id and viewing_user_id == profile_user.id: + return True + + # Get privacy settings for this user + privacy_settings = profile_user.get_privacy_settings() + privacy_level = privacy_settings.get(setting_name, "private") + + # Public settings are visible to everyone + if privacy_level == "public": + return True + + # Private settings are only visible to the user and admins (handled above) + if privacy_level == "private": + return False + + # For "friends" level (team members), check if viewing user is in same team + if privacy_level == "friends" and viewing_user_id: + # Get teams of the profile user + profile_user_team_ids = [ + membership.team_id + for membership in db.query(TeamMembership).filter( + TeamMembership.user_id == profile_user.id + ).all() + ] + + # Check if viewing user is in any of the same teams + common_team = db.query(TeamMembership).filter( + TeamMembership.user_id == viewing_user_id, + TeamMembership.team_id.in_(profile_user_team_ids) + ).first() + + return common_team is not None + + return False + +def get_viewable_profile_data( + db: Session, + profile_user: User, + viewing_user_id: Optional[int] +) -> Dict[str, Any]: + """ + Get profile data respecting privacy settings + + Args: + db: Database session + profile_user: The user whose profile is being viewed + viewing_user_id: The ID of the user viewing the profile (None if not logged in) + + Returns: + Dictionary with profile data that the viewing user is allowed to see + """ + data = { + "username": profile_user.username, + "picture": profile_user.picture, + "is_admin": profile_user.is_admin, + "created_at": profile_user.created_at + } + + # Only include email if permission allows + if check_privacy_permission(db, profile_user, viewing_user_id, "email"): + data["email"] = profile_user.email + + # Only include full name if permission allows + if check_privacy_permission(db, profile_user, viewing_user_id, "full_name"): + data["first_name"] = profile_user.first_name + data["last_name"] = profile_user.last_name + + # For teams, points, achievements, events - we'll just include permission flags + # The actual data will be loaded by the view functions when needed + data["can_view_teams"] = check_privacy_permission(db, profile_user, viewing_user_id, "teams") + data["can_view_points"] = check_privacy_permission(db, profile_user, viewing_user_id, "points") + data["can_view_achievements"] = check_privacy_permission(db, profile_user, viewing_user_id, "achievements") + data["can_view_events"] = check_privacy_permission(db, profile_user, viewing_user_id, "events") + + return data + # Note: For new code, consider using the decorators in app.auth.permissions instead # of these dependency functions directly diff --git a/app/views/auth.py b/app/views/auth.py index 4907544..94dd495 100644 --- a/app/views/auth.py +++ b/app/views/auth.py @@ -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: