From 6c260c59360c18ef85600b1ef000dc4830b21c0c Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Wed, 16 Apr 2025 21:45:33 +0200 Subject: [PATCH] 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. --- app/db_init.py | 60 ++- app/db_migrations.py | 66 ++- app/models.py | 22 + .../e9d2a6f4-861e-4da7-986c-9784bf5cb175.png | Bin 0 -> 5931 bytes app/templates/auth/account_deleted.html | 28 ++ app/templates/auth/privacy_settings.html | 152 +++++++ app/templates/auth/profile.html | 53 ++- app/templates/auth/view_profile.html | 123 ++++++ app/templates/base.html | 16 +- app/templates/dashboard/index.html | 2 +- app/templates/index.html | 6 +- app/templates/profile.html | 93 +++-- app/templates/team_detail.html | 14 +- app/templates/teams.html | 186 ++++----- app/utils/auth.py | 103 ++++- app/views/auth.py | 390 +++++++++++++++++- 16 files changed, 1169 insertions(+), 145 deletions(-) create mode 100644 app/static/uploads/profile_pictures/e9d2a6f4-861e-4da7-986c-9784bf5cb175.png create mode 100644 app/templates/auth/account_deleted.html create mode 100644 app/templates/auth/privacy_settings.html create mode 100644 app/templates/auth/view_profile.html 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 0000000000000000000000000000000000000000..0d627c90fb8900428bb44575c08c26358fa1721a GIT binary patch literal 5931 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 +
+
+ +
+ +

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 @@ 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: