diff --git a/app/auth/oauth.py b/app/auth/oauth.py new file mode 100644 index 0000000..879adb9 --- /dev/null +++ b/app/auth/oauth.py @@ -0,0 +1,98 @@ +import os +from httpx_oauth.clients.openid import OpenID +from httpx_oauth.oauth2 import GetAccessTokenError +from fastapi import HTTPException, Request +from starlette.responses import RedirectResponse +from typing import Optional, Dict, Any +import json +import httpx +from urllib.parse import urlencode + +class AuthentikOAuth: + def __init__(self): + self.client_id = os.getenv("AUTHENTIK_CLIENT_ID", "dRXLBdTdG6JSHqkcM0ZQBPwBVMBrG6SF32LZ1XAT") + self.client_secret = os.getenv("AUTHENTIK_CLIENT_SECRET", + "hn1aKecLeYj1tVc7QtsavrWjSOF4t7Ty1akVTmUqvIFJF1y0H3Myv7InUxAX6E2GLpMxxhhZZ2aUSJ9VEQz7zGcMbgUeMStxx2U7bEQxmuOGjZf0XJbOBGjdwGZYJlz7") + self.config_url = os.getenv("AUTHENTIK_CONFIG_URL", + "https://authentik.hosterra.net/application/o/leagueledger/.well-known/openid-configuration") + self.client = None + self.initialize_client() + + def initialize_client(self): + try: + self.client = OpenID( + client_id=self.client_id, + client_secret=self.client_secret, + openid_configuration_endpoint=self.config_url, + ) + except Exception as e: + print(f"Error initializing Authentik OAuth client: {str(e)}") + self.client = None + + async def get_login_url(self, request: Request, redirect_uri: str) -> str: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="OAuth client could not be initialized") + + try: + authorization_url = await self.client.get_authorization_url( + redirect_uri=redirect_uri, + scope=["openid", "email", "profile"], + state=str(request.session.get("session_id", "")), + ) + return authorization_url + except Exception as e: + print(f"Error getting authorization URL: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="OAuth client could not be initialized") + + try: + # Exchange code for token + token = await self.client.get_access_token( + code=code, + redirect_uri=redirect_uri, + ) + + access_token = token.get("access_token") + if not access_token: + raise HTTPException(status_code=400, detail="Could not get access token") + + # Get user info from OpenID userinfo endpoint + async with httpx.AsyncClient() as client: + # Get the configuration to find the userinfo_endpoint + config_response = await client.get(self.config_url) + if config_response.status_code != 200: + raise HTTPException(status_code=500, detail="Could not fetch OpenID configuration") + + config = config_response.json() + userinfo_endpoint = config.get("userinfo_endpoint") + + if not userinfo_endpoint: + raise HTTPException(status_code=500, detail="UserInfo endpoint not found in OpenID configuration") + + # Make request to userinfo endpoint + headers = {"Authorization": f"Bearer {access_token}"} + user_response = await client.get(userinfo_endpoint, headers=headers) + + if user_response.status_code != 200: + raise HTTPException(status_code=500, detail=f"Error fetching user info: {user_response.text}") + + return user_response.json() + + except GetAccessTokenError as e: + error_description = e.args[0] + raise HTTPException(status_code=400, detail=f"OAuth error: {error_description}") + except Exception as e: + print(f"Error getting user info: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + +# Instantiate the OAuth client for the application to use +authentik_oauth = AuthentikOAuth() diff --git a/app/db_migrations.py b/app/db_migrations.py new file mode 100644 index 0000000..cac2be1 --- /dev/null +++ b/app/db_migrations.py @@ -0,0 +1,87 @@ +from sqlalchemy import text +from .db import engine + +def apply_migrations(): + """Apply all pending database migrations.""" + + # Check and add OAuth columns to users table + try: + with engine.connect() as conn: + # Check if the OAuth columns exist + result = conn.execute(text(""" + SELECT COUNT(*) as count + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'users' + AND column_name = 'is_oauth_user' + """)) + + if result.fetchone()[0] == 0: + print("Adding OAuth columns to users table...") + + # Add the OAuth columns + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN is_oauth_user BOOLEAN DEFAULT FALSE, + ADD COLUMN oauth_id VARCHAR(255) NULL, + ADD COLUMN oauth_provider VARCHAR(50) NULL, + ADD COLUMN picture VARCHAR(255) NULL + """)) + + conn.commit() + print("OAuth columns added successfully.") + else: + print("OAuth columns already exist in users table.") + + # Check if the is_admin column exists in the users table + result = conn.execute(text(""" + SELECT COUNT(*) as count + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'users' + AND column_name = 'is_admin' + """)) + + if result.fetchone()[0] == 0: + print("Adding is_admin column to users table...") + + # Add the is_admin column to users table + conn.execute(text(""" + ALTER TABLE users + ADD COLUMN is_admin BOOLEAN DEFAULT FALSE + """)) + + conn.commit() + print("is_admin column added successfully to users table.") + else: + print("is_admin column already exists in users table.") + + # Check if the owner_id column exists in the teams table + result = conn.execute(text(""" + SELECT COUNT(*) as count + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'teams' + AND column_name = 'owner_id' + """)) + + if result.fetchone()[0] == 0: + print("Adding owner_id column to teams table...") + + # Add the owner_id column to teams table + conn.execute(text(""" + ALTER TABLE teams + ADD COLUMN owner_id INT NULL, + ADD CONSTRAINT fk_teams_owner + FOREIGN KEY (owner_id) REFERENCES users(id) + ON DELETE SET NULL + """)) + + conn.commit() + print("owner_id column added successfully to teams table.") + else: + print("owner_id column already exists in teams table.") + + except Exception as e: + print(f"Error applying migrations: {str(e)}") + raise diff --git a/app/main.py b/app/main.py index e7794b1..c8b437a 100644 --- a/app/main.py +++ b/app/main.py @@ -6,16 +6,24 @@ from fastapi.templating import Jinja2Templates from pathlib import Path import os from starlette.middleware.sessions import SessionMiddleware +from dotenv import load_dotenv -from .db import init_db, engine +from .db import init_db, engine, get_db from . import models from .templates_config import templates -from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages +from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth from .db_init import seed_db +from .db_migrations import apply_migrations + +# Load environment variables +load_dotenv() # Create tables on startup init_db() +# Apply any pending database migrations +apply_migrations() + # Seed database with initial test data # In a production app, you would handle this differently seed_db() @@ -24,7 +32,7 @@ seed_db() app = FastAPI(title="LeagueLedger") # Add SessionMiddleware with a secure secret key -app.add_middleware(SessionMiddleware, secret_key="your-very-secret-session-key") +app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET_KEY", "your-very-secret-session-key")) # Mount static files app.mount("/static", StaticFiles(directory="app/static"), name="static") @@ -41,7 +49,15 @@ async def add_template_globals(request: Request, call_next): """Add template globals""" try: # Update template globals for all templates - templates.env.globals["current_user"] = None + user = None + if "user_id" in request.session and request.session.get("is_authenticated"): + # Mock user object - in a real app, you'd fetch this from the database + user = { + "id": request.session["user_id"], + "username": request.session.get("username", "User"), + "is_admin": request.session.get("is_admin", False) + } + templates.env.globals["current_user"] = user except Exception as e: print(f"Error setting template globals: {str(e)}") @@ -51,13 +67,21 @@ async def add_template_globals(request: Request, call_next): @app.get("/", response_class=HTMLResponse) async def read_root(request: Request): + user = None + if "user_id" in request.session and request.session.get("is_authenticated"): + user = { + "id": request.session["user_id"], + "username": request.session.get("username", "User"), + "is_admin": request.session.get("is_admin", False) + } return templates.TemplateResponse( "index.html", - {"request": request, "user": None} + {"request": request, "user": user} ) # Routers app.include_router(pages.router, tags=["Pages"]) # Pages router for index and static pages +app.include_router(auth.router) # Include the auth router app.include_router(qr.router, prefix="/qr", tags=["QR"]) app.include_router(redeem.router, prefix="/redeem", tags=["Redeem"]) app.include_router(teams.router, prefix="/teams", tags=["Teams"]) @@ -70,3 +94,20 @@ app.include_router(static.router, tags=["Static"]) # Include the static router @app.get("/scan") async def scan_redirect(): return RedirectResponse("/redeem/scan", status_code=303) + +# Add convenience routes for auth paths +@app.get("/login") +async def login_redirect(): + return RedirectResponse("/auth/login", status_code=303) + +@app.get("/register") +async def register_redirect(): + return RedirectResponse("/auth/register", status_code=303) + +@app.get("/profile") +async def profile_redirect(): + return RedirectResponse("/auth/profile", status_code=303) + +@app.get("/logout") +async def logout_redirect(): + return RedirectResponse("/auth/logout", status_code=303) diff --git a/app/models.py b/app/models.py index 6a7a3e5..e20ebad 100644 --- a/app/models.py +++ b/app/models.py @@ -2,15 +2,16 @@ from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float from sqlalchemy.orm import relationship from sqlalchemy.sql import func -from datetime import datetime -from .db import Base +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, index=True, nullable=False) - email = Column(String(255), unique=True, index=True, nullable=False) - hashed_password = Column(String(255), nullable=True) + email = Column(String(100), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=True) # Can be null for OAuth users created_at = Column(DateTime, server_default=func.now()) updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) is_active = Column(Boolean, default=True) @@ -21,11 +22,21 @@ class User(Base): reset_token_expires_at = Column(DateTime, nullable=True) last_login = Column(DateTime, nullable=True) + # OAuth fields + is_oauth_user = Column(Boolean, default=False) + oauth_id = Column(String(255), nullable=True) + oauth_provider = Column(String(50), nullable=True) + picture = Column(String(255), nullable=True) # URL to profile picture + # Relationships memberships = relationship("TeamMembership", back_populates="user") teams = relationship("TeamMember", back_populates="user") points = relationship("UserPoints", back_populates="user") events_attended = relationship("EventAttendee", back_populates="user") + owned_teams = relationship("Team", back_populates="owner") + + def __repr__(self): + return f"" class OAuthAccount(Base): @@ -50,10 +61,12 @@ class Team(Base): is_public = Column(Boolean, default=False) # For team privacy setting created_at = Column(DateTime, server_default=func.now()) # For team founded date updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) - + owner_id = Column(Integer, ForeignKey("users.id"), nullable=True) + # Relationships memberships = relationship("TeamMembership", back_populates="team") members = relationship("TeamMember", back_populates="team") + owner = relationship("User", back_populates="owned_teams") class TeamMembership(Base): diff --git a/app/requirements.txt b/app/requirements.txt deleted file mode 100644 index a234e83..0000000 --- a/app/requirements.txt +++ /dev/null @@ -1,8 +0,0 @@ -fastapi==0.88.0 -uvicorn==0.20.0 -jinja2==3.1.6 -babel==2.11.0 -python-gettext==5.0 -aiofiles==22.1.0 -python-multipart==0.0.18 -pydantic==1.10.13 diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index afcb07e..e2c2391 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -78,7 +78,7 @@

Or sign in with

- {{ oauth_provider_name }} + Authentik
diff --git a/app/templates/dashboard/index.html b/app/templates/dashboard/index.html index 33d57be..9a3e278 100644 --- a/app/templates/dashboard/index.html +++ b/app/templates/dashboard/index.html @@ -50,6 +50,36 @@ {% endif %} + +
+
+

Your Teams

+ View All +
+ + {% if user_teams %} +
+ {% for team in user_teams %} +
+

{{ team.name }}

+
+ Rank: #{{ team.rank|default('N/A') }} + {{ team.points|default(0) }} pts +
+ + View Team + +
+ {% endfor %} +
+ {% else %} +

You haven't joined any teams yet.

+ + Join a Team + + {% endif %} +
+
diff --git a/app/templates/team_detail.html b/app/templates/team_detail.html index c4a109d..82448c8 100644 --- a/app/templates/team_detail.html +++ b/app/templates/team_detail.html @@ -16,9 +16,21 @@
- + {% if not user %} + + Login to Join + + {% elif not is_team_member %} +
+ +
+ {% else %} + + {% endif %}
@@ -156,34 +168,54 @@ + {% if is_user_admin %}

Team Management

-
- -
- - + {% if is_user_owner %} +
+

You are the owner of this team

-
- -
- -
- - + {% elif is_user_admin %} +
+

You are an admin of this team

-
+ {% endif %} -
- -
+
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+
+ {% endif %} + + + {% if is_team_member and not is_user_owner %} +
+

Team Membership

+

You are currently a member of this team.

+
+ +
+
+ {% endif %}
{% endblock %} diff --git a/app/templates/teams.html b/app/templates/teams.html index 2e14d9c..a6ab628 100644 --- a/app/templates/teams.html +++ b/app/templates/teams.html @@ -1,6 +1,13 @@ {% extends "base.html" %} {% block content %}

Teams

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

Available Teams

@@ -8,14 +15,26 @@
    {% for team in teams %}
  • - {{ team.name }} -
    - -
    + {{ team.name }} + {% if user %} + {% if team.id in user_team_ids %} + Member + {% else %} +
    + +
    + {% endif %} + {% else %} + + Login to Join + + {% endif %}
  • {% endfor %}
@@ -26,22 +45,56 @@

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 }}

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

About Teams

diff --git a/app/views/auth.py b/app/views/auth.py new file mode 100644 index 0000000..30738c9 --- /dev/null +++ b/app/views/auth.py @@ -0,0 +1,208 @@ +from fastapi import APIRouter, Request, Depends, Form, HTTPException, status +from fastapi.responses import HTMLResponse, RedirectResponse +from fastapi.templating import Jinja2Templates +from typing import Optional +import secrets +import os +import uuid +from starlette.status import HTTP_303_SEE_OTHER, HTTP_302_FOUND +from sqlalchemy.orm import Session + +from ..db import get_db +from ..models import User +from ..auth.oauth import authentik_oauth +from ..templates_config import templates + +router = APIRouter(prefix="/auth", tags=["Auth"]) + +@router.get("/login", response_class=HTMLResponse) +async def login_page(request: Request, error: Optional[str] = None, message: Optional[str] = None): + """Login page route""" + return templates.TemplateResponse( + "auth/login.html", + {"request": request, "error": error, "message": message, + "show_oauth": True, "oauth_provider_name": "Authentik"} + ) + +@router.post("/login", response_class=HTMLResponse) +async def login_post( + request: Request, + username: str = Form(...), + password: str = Form(...), + remember: Optional[bool] = Form(False), + db: Session = Depends(get_db) +): + """Handle login form submission""" + # This is a placeholder - implement real login logic here + error = "This login method is not fully implemented yet" + return templates.TemplateResponse( + "auth/login.html", + {"request": request, "error": error, "show_oauth": True, "oauth_provider_name": "Authentik"} + ) + +@router.get("/register", response_class=HTMLResponse) +async def register_page(request: Request, error: Optional[str] = None): + """Registration page route""" + return templates.TemplateResponse( + "auth/register.html", + {"request": request, "error": error} + ) + +@router.post("/register", response_class=HTMLResponse) +async def register_post( + request: Request, + username: str = Form(...), + email: str = Form(...), + password: str = Form(...), + confirm_password: str = Form(...), + db: Session = Depends(get_db) +): + """Handle registration form submission""" + # This is a placeholder - implement real registration logic here + if password != confirm_password: + return templates.TemplateResponse( + "auth/register.html", + {"request": request, "error": "Passwords do not match"} + ) + + # Check username and email uniqueness, then create user + return templates.TemplateResponse( + "auth/registration_success.html", + {"request": request} + ) + +@router.get("/oauth-login") +async def oauth_login(request: Request): + """Start the OAuth login flow""" + # Generate the redirect URI + base_url = str(request.base_url) + redirect_uri = f"{base_url}auth/oauth-callback" + + # Request a login URL from the Authentik provider + try: + # Set a session ID to validate the callback + if "session_id" not in request.session: + request.session["session_id"] = str(uuid.uuid4()) + + # Get the authorization URL - make sure to await it + auth_url = await authentik_oauth.get_login_url(request, redirect_uri) + + # Redirect to the authorization URL + return RedirectResponse(auth_url) + except Exception as e: + print(f"OAuth login error: {str(e)}") + return RedirectResponse( + f"/auth/login?error=OAuth+login+failed:+{str(e)}", + status_code=HTTP_303_SEE_OTHER + ) + +@router.get("/oauth-callback") +async def oauth_callback(request: Request, code: Optional[str] = None, state: Optional[str] = None, error: Optional[str] = None, db: Session = Depends(get_db)): + """Handle the OAuth callback""" + if error: + return RedirectResponse( + f"/auth/login?error=OAuth+login+failed:+{error}", + status_code=HTTP_303_SEE_OTHER + ) + + if not code: + return RedirectResponse( + "/auth/login?error=No+authorization+code+received", + status_code=HTTP_303_SEE_OTHER + ) + + # Generate the redirect URI that matches the one used in the initial request + base_url = str(request.base_url) + redirect_uri = f"{base_url}auth/oauth-callback" + + try: + # Get user info from the provider + user_info = await authentik_oauth.get_user_info(request, redirect_uri, code) + + if not user_info: + return RedirectResponse( + "/auth/login?error=Could+not+retrieve+user+information", + status_code=HTTP_303_SEE_OTHER + ) + + # Extract user details from OAuth info + sub = user_info.get("sub", "") + email = user_info.get("email", "") + name = user_info.get("preferred_username", "") or user_info.get("name", "") or email.split("@")[0] + picture = user_info.get("picture", None) + + # Check if the user already exists + user = db.query(User).filter(User.email == email).first() + + if not user: + print(f"Creating new user with email {email} and username {name}") + # Create a new user + user = User( + username=name, + email=email, + oauth_id=sub, + is_oauth_user=True, + oauth_provider="authentik", + picture=picture, + is_verified=True # OAuth users are considered verified + ) + db.add(user) + db.commit() + db.refresh(user) + else: + # Update existing user's OAuth information + if not user.is_oauth_user: + user.is_oauth_user = True + user.oauth_id = sub + user.oauth_provider = "authentik" + + # Update profile picture if available + if picture and not user.picture: + user.picture = picture + + db.commit() + + # Set session data + request.session["user_id"] = user.id + request.session["username"] = user.username + request.session["is_authenticated"] = True + request.session["is_admin"] = user.is_admin + + return RedirectResponse("/dashboard", status_code=HTTP_303_SEE_OTHER) + + except Exception as e: + print(f"OAuth callback error: {str(e)}") + return RedirectResponse( + f"/auth/login?error=OAuth+login+failed:+{str(e)}", + status_code=HTTP_303_SEE_OTHER + ) + +@router.get("/logout") +async def logout(request: Request): + """Log out the user""" + request.session.clear() + return RedirectResponse("/", status_code=HTTP_303_SEE_OTHER) + +@router.get("/profile", response_class=HTMLResponse) +async def profile_page(request: Request): + """User profile page""" + # Get the user ID from the session + user_id = request.session.get("user_id") + + if not user_id: + return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER) + + # Mock user data - in a real app, you'd fetch this from the database + user = { + "id": user_id, + "username": request.session.get("username", "User"), + "email": "user@example.com", + "is_admin": request.session.get("is_admin", False), + "created_at": "2023-01-01 12:00:00", + "picture": None + } + + return templates.TemplateResponse( + "auth/profile.html", + {"request": request, "user": user} + ) diff --git a/app/views/dashboard.py b/app/views/dashboard.py index af2d17d..d5b813e 100644 --- a/app/views/dashboard.py +++ b/app/views/dashboard.py @@ -14,13 +14,17 @@ router = APIRouter() def user_dashboard(request: Request, db: Session = Depends(get_db)): """User dashboard showing teams, events and stats""" try: - # Fetch user data directly from the database - user = db.query(models.User).filter_by(email="admin@example.com").first() # Example user lookup + # Get user ID from session instead of using hardcoded admin + user_id = request.session.get("user_id") + if not user_id: + # Redirect to login if not authenticated + return RedirectResponse("/auth/login", status_code=303) + + # Fetch user data from the database using session user ID + user = db.query(models.User).get(user_id) if not user: raise HTTPException(status_code=404, detail="User not found") - user_id = user.id - # Initialize default values in case of errors team_count = 0 total_points = 0 @@ -28,9 +32,20 @@ def user_dashboard(request: Request, db: Session = Depends(get_db)): recent_events = [] user_teams = [] - # Check if TeamMember model exists before querying - if hasattr(models, "TeamMember"): - # Get the team count for this user + # Get team memberships - check both TeamMember and TeamMembership models + if hasattr(models, "TeamMembership"): + # Primary check - use TeamMembership model + team_count = db.query(func.count(models.TeamMembership.team_id))\ + .filter(models.TeamMembership.user_id == user_id)\ + .scalar() or 0 + + # Get user teams + user_teams = db.query(models.Team)\ + .join(models.TeamMembership)\ + .filter(models.TeamMembership.user_id == user_id)\ + .all() + elif hasattr(models, "TeamMember"): + # Fallback to TeamMember model if TeamMembership doesn't exist team_count = db.query(func.count(models.TeamMember.team_id))\ .filter(models.TeamMember.user_id == user_id)\ .scalar() or 0 @@ -41,15 +56,23 @@ def user_dashboard(request: Request, db: Session = Depends(get_db)): .filter(models.TeamMember.user_id == user_id)\ .all() - # Check if UserPoints model exists before querying - if hasattr(models, "UserPoints"): - # Get the total points safely - total_points_result = db.query(func.sum(models.UserPoints.points))\ - .filter(models.UserPoints.user_id == user_id)\ - .first() + # Get total points from QRCode redemptions + # First try directly from QRCodes tied to user + total_points_result = db.query(func.sum(models.QRCode.points))\ + .filter(models.QRCode.redeemed_by == user_id, models.QRCode.used == True)\ + .first() - if total_points_result and total_points_result[0]: - total_points = total_points_result[0] + if total_points_result and total_points_result[0]: + total_points = total_points_result[0] + else: + # Fallback to UserPoints model if available + if hasattr(models, "UserPoints"): + points_result = db.query(func.sum(models.UserPoints.points))\ + .filter(models.UserPoints.user_id == user_id)\ + .first() + + if points_result and points_result[0]: + total_points = points_result[0] # Check if EventAttendee model exists before querying if hasattr(models, "EventAttendee") and hasattr(models, "Event"): diff --git a/app/views/leaderboard.py b/app/views/leaderboard.py index 2292282..39c83c5 100644 --- a/app/views/leaderboard.py +++ b/app/views/leaderboard.py @@ -9,7 +9,7 @@ from sqlalchemy import func, desc from datetime import datetime, timedelta from ..db import SessionLocal -from ..models import Team, TeamMembership, QRCode +from ..models import Team, TeamMembership, QRCode, User from ..templates_config import templates router = APIRouter() @@ -29,6 +29,12 @@ async def show_leaderboard( ): """Show the leaderboard with team rankings.""" + # Get user from session for navbar + user = None + user_id = request.session.get("user_id") + if user_id: + user = db.query(User).get(user_id) + # Define cutoff date based on timeframe cutoff_date = None if timeframe == "week": @@ -81,6 +87,7 @@ async def show_leaderboard( "teams": ranked_teams, "top_teams": top_teams, "timeframe": timeframe, - "time_label": time_label + "time_label": time_label, + "user": user # Add user to the context } ) diff --git a/app/views/pages.py b/app/views/pages.py index 3777192..6c08ded 100644 --- a/app/views/pages.py +++ b/app/views/pages.py @@ -2,45 +2,111 @@ """ Router for static content pages like about, contact, privacy, and terms. """ -from fastapi import APIRouter, Request +from fastapi import APIRouter, Request, Depends from fastapi.responses import HTMLResponse from ..templates_config import templates from datetime import datetime +from sqlalchemy.orm import Session +from ..db import get_db +from .. import models router = APIRouter() @router.get("/", response_class=HTMLResponse) -def index(request: Request): +def index(request: Request, db: Session = Depends(get_db)): """Home page.""" + # Get user from session for navbar + user = None + user_id = request.session.get("user_id") + if user_id: + user = db.query(models.User).get(user_id) + return templates.TemplateResponse("index.html", { "request": request, - "now": datetime.now + "now": datetime.now, + "user": user }) @router.get("/about", response_class=HTMLResponse) -def about(request: Request): +def about(request: Request, db: Session = Depends(get_db)): """About page.""" + # Get user from session for navbar + user = None + user_id = request.session.get("user_id") + if user_id: + user = db.query(models.User).get(user_id) + return templates.TemplateResponse("about.html", { - "request": request + "request": request, + "user": user }) @router.get("/contact", response_class=HTMLResponse) -def contact(request: Request): +def contact(request: Request, db: Session = Depends(get_db)): """Contact page.""" + # Get user from session for navbar + user = None + user_id = request.session.get("user_id") + if user_id: + user = db.query(models.User).get(user_id) + return templates.TemplateResponse("contact.html", { - "request": request + "request": request, + "user": user }) @router.get("/privacy", response_class=HTMLResponse) -def privacy(request: Request): +def privacy(request: Request, db: Session = Depends(get_db)): """Privacy policy page.""" + # Get user from session for navbar + user = None + user_id = request.session.get("user_id") + if user_id: + user = db.query(models.User).get(user_id) + return templates.TemplateResponse("privacy.html", { - "request": request + "request": request, + "user": user }) @router.get("/terms", response_class=HTMLResponse) -def terms(request: Request): +def terms(request: Request, db: Session = Depends(get_db)): """Terms and conditions page.""" + # Get user from session for navbar + user = None + user_id = request.session.get("user_id") + if user_id: + user = db.query(models.User).get(user_id) + return templates.TemplateResponse("terms.html", { - "request": request + "request": request, + "user": user + }) + +@router.get("/cookies", response_class=HTMLResponse) +def cookies(request: Request, db: Session = Depends(get_db)): + """Cookie policy page.""" + # Get user from session for navbar + user = None + user_id = request.session.get("user_id") + if user_id: + user = db.query(models.User).get(user_id) + + return templates.TemplateResponse("cookies.html", { + "request": request, + "user": user + }) + +@router.get("/impressum", response_class=HTMLResponse) +def impressum(request: Request, db: Session = Depends(get_db)): + """Imprint/Impressum page.""" + # Get user from session for navbar + user = None + user_id = request.session.get("user_id") + if user_id: + user = db.query(models.User).get(user_id) + + return templates.TemplateResponse("impressum.html", { + "request": request, + "user": user }) diff --git a/app/views/teams.py b/app/views/teams.py index 4ab94f8..e2ba0bb 100644 --- a/app/views/teams.py +++ b/app/views/teams.py @@ -10,7 +10,7 @@ from datetime import datetime, timedelta import random # For demo data from ..db import SessionLocal -from ..models import Team, TeamMembership, User, QRCode, TeamAchievement +from ..models import Team, TeamMembership, User, QRCode, TeamAchievement, TeamMember from ..schemas import TeamCreate from ..templates_config import templates @@ -30,12 +30,26 @@ def list_teams(request: Request, db: Session = Depends(get_db)): # Get the user's teams to highlight teams they're already in user_team_ids = [] + # Get user from session for navbar + user = None + user_id = request.session.get("user_id") + if user_id: + user = db.query(User).get(user_id) + # Get teams that user is a member of + memberships = db.query(TeamMembership).filter(TeamMembership.user_id == user_id).all() + user_team_ids = [membership.team_id for membership in memberships] + + # Get error message if present + error = request.query_params.get("error") + return templates.TemplateResponse( "teams.html", { "request": request, "teams": teams, "user_team_ids": user_team_ids, + "user": user, + "error": error, "brand_colors": { "irish_green": "#006837", "golden_ale": "#FFB400", @@ -48,31 +62,153 @@ def list_teams(request: Request, db: Session = Depends(get_db)): @router.post("/create") def create_team(request: Request, name: str = Form(...), db: Session = Depends(get_db)): - # Create team - new_team = Team(name=name) + # Check if user is logged in + user_id = request.session.get("user_id") + if not user_id: + return RedirectResponse("/auth/login?next=/teams", status_code=303) + + # Get the user + user = db.query(User).get(user_id) + if not user: + return RedirectResponse("/auth/login", status_code=303) + + # Check if team name already exists + existing_team = db.query(Team).filter(Team.name == name).first() + if existing_team: + # Return to teams page with error message + # In a real app, you'd add error handling/flash messages + return RedirectResponse("/teams/?error=Team+name+already+exists", status_code=303) + + # Create team with the user as owner + new_team = Team(name=name, owner_id=user_id) db.add(new_team) db.commit() db.refresh(new_team) + + # Make the user an admin of the team in TeamMembership + team_membership = TeamMembership( + user_id=user_id, + team_id=new_team.id, + is_admin=True # User becomes admin of the team + ) + db.add(team_membership) + + # Check if TeamMember model exists in the database + try: + # Use a safer approach to check if the model exists and is usable + if 'team_members' in inspect(db.bind).get_table_names(): + # Create TeamMember relationship as well + team_member = TeamMember( + user_id=user_id, + team_id=new_team.id, + is_captain=True # User becomes captain in TeamMember model + ) + db.add(team_member) + except Exception as e: + print(f"Could not create TeamMember record: {str(e)}") + # Continue even if this fails - TeamMembership is primary relationship + + db.commit() return RedirectResponse("/teams/", status_code=303) @router.post("/join/{team_id}") def join_team(request: Request, team_id: int, db: Session = Depends(get_db)): + # Check if user is logged in + user_id = request.session.get("user_id") + if not user_id: + return RedirectResponse("/auth/login?next=/teams", status_code=303) + + # Get user + user = db.query(User).get(user_id) + if not user: + return RedirectResponse("/auth/login", status_code=303) + + # Find the team team = db.query(Team).filter_by(id=team_id).first() if not team: - return RedirectResponse("/teams/", status_code=303) + return RedirectResponse("/teams/?error=Team+not+found", status_code=303) # Check if membership exists - existing = db.query(TeamMembership).filter_by(team_id=team.id).first() + existing = db.query(TeamMembership)\ + .filter_by(user_id=user_id, team_id=team.id)\ + .first() + if existing: - return RedirectResponse("/teams/", status_code=303) + return RedirectResponse("/teams/?error=You+are+already+a+member+of+this+team", status_code=303) # Create membership - new_member = TeamMembership(team_id=team.id, is_admin=False) + new_member = TeamMembership( + user_id=user_id, + team_id=team.id, + is_admin=False + ) db.add(new_member) db.commit() - return RedirectResponse("/teams/", status_code=303) + # Redirect to the team detail page + return RedirectResponse(f"/teams/{team_id}", status_code=303) + +@router.post("/{team_id}/leave") +def leave_team(request: Request, team_id: int, db: Session = Depends(get_db)): + """Allow a user to leave a team""" + # Check if user is logged in + user_id = request.session.get("user_id") + if not user_id: + return RedirectResponse("/auth/login?next=/teams", status_code=303) + + # Get team + team = db.query(Team).filter_by(id=team_id).first() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + # Can't leave if you're the owner + if team.owner_id == user_id: + return RedirectResponse(f"/teams/{team_id}?error=Team+owner+cannot+leave", status_code=303) + + # Find membership + membership = db.query(TeamMembership)\ + .filter(TeamMembership.user_id == user_id, TeamMembership.team_id == team_id)\ + .first() + + if not membership: + return RedirectResponse("/teams/?error=You+are+not+a+member+of+this+team", status_code=303) + + # Delete the team membership + db.delete(membership) + db.commit() + + return RedirectResponse("/teams/?message=Successfully+left+the+team", status_code=303) + +@router.post("/{team_id}/update") +def update_team( + request: Request, + team_id: int, + team_name: str = Form(...), + is_public: bool = Form(False), + db: Session = Depends(get_db) +): + """Update team details.""" + team = db.query(Team).filter_by(id=team_id).first() + + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + # Check if user is admin + membership = db.query(TeamMembership).filter_by( + team_id=team.id, + is_admin=True + ).first() + + if not membership: + raise HTTPException(status_code=403, detail="You don't have permission to update this team") + + # Update team details + team.name = team_name + team.is_public = is_public + db.commit() + + return RedirectResponse(f"/teams/{team_id}", status_code=303) @router.get("/{team_id}", response_class=HTMLResponse) def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)): @@ -82,6 +218,21 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)): if not team: raise HTTPException(status_code=404, detail="Team not found") + # Get user from session for navbar + user = None + user_id = request.session.get("user_id") + is_team_member = False + + if user_id: + user = db.query(User).get(user_id) + + # Check if user is a team member + team_membership = db.query(TeamMembership)\ + .filter(TeamMembership.user_id == user_id, TeamMembership.team_id == team_id)\ + .first() + + is_team_member = team_membership is not None + # Get team members with admin status memberships = db.query(TeamMembership).filter_by(team_id=team_id).all() team_members = [] @@ -90,6 +241,10 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)): for membership in memberships: member = db.query(User).filter_by(id=membership.user_id).first() if member: + # Check if current user is admin of this team + if user and user.id == membership.user_id and membership.is_admin: + is_user_admin = True + # Use joined_at if available, otherwise use placeholder joined_date = getattr(membership, 'joined_at', None) or datetime.now() - timedelta(days=random.randint(30, 180)) if isinstance(joined_date, datetime): @@ -105,21 +260,39 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)): "joined": f"{month_name} {year}" }) + # Also check if user is the owner + is_user_owner = user and team.owner_id == user.id + if is_user_owner: + is_user_admin = True # Owner has admin privileges + # Get total points total_points = db.query(func.sum(QRCode.points)).filter( QRCode.redeemed_at_team == team_id ).scalar() or 0 - # Calculate rank based on points - higher_teams = db.query(func.count(Team.id)).join( - QRCode, - QRCode.redeemed_at_team == Team.id, - isouter=True - ).group_by(Team.id).having( - func.sum(QRCode.points) > total_points - ).scalar() or 0 - - team_rank = higher_teams + 1 + # Calculate rank based on points - using a safer approach + try: + # First, get the aggregated points for all teams + team_points = db.query( + QRCode.redeemed_at_team, + func.sum(QRCode.points).label('total') + ).filter( + QRCode.redeemed_at_team != None + ).group_by(QRCode.redeemed_at_team).all() + + # Sort them by points (descending) + sorted_teams = sorted(team_points, key=lambda x: x.total or 0, reverse=True) + + # Find our team's position + team_rank = 1 + for idx, team_data in enumerate(sorted_teams): + if team_data.redeemed_at_team == team_id: + team_rank = idx + 1 + break + + except Exception as e: + print(f"Error calculating team rank: {e}") + team_rank = 1 # Default to 1st place on error # Generate points data (with fallbacks for missing columns) points_this_month = 65 # Default value @@ -205,37 +378,10 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)): "activities": activities, "performance": performance, "is_user_admin": is_user_admin, + "is_user_owner": is_user_owner, + "is_team_member": is_team_member, "days_ago": days_ago, - "founded_date": founded_date_str + "founded_date": founded_date_str, + "user": user # Add user to the context } ) - -@router.post("/{team_id}/update") -def update_team( - request: Request, - team_id: int, - team_name: str = Form(...), - is_public: bool = Form(False), - db: Session = Depends(get_db) -): - """Update team details.""" - team = db.query(Team).filter_by(id=team_id).first() - - if not team: - raise HTTPException(status_code=404, detail="Team not found") - - # Check if user is admin - membership = db.query(TeamMembership).filter_by( - team_id=team.id, - is_admin=True - ).first() - - if not membership: - raise HTTPException(status_code=403, detail="You don't have permission to update this team") - - # Update team details - team.name = team_name - team.is_public = is_public - db.commit() - - return RedirectResponse(f"/teams/{team_id}", status_code=303) diff --git a/requirements.txt b/requirements.txt index 1feb892..c3b850d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,9 @@ passlib>=1.7.4 itsdangerous>=2.1.2 bcrypt>=4.0.1 +# OAuth client +httpx-oauth>=0.10.0 + # Templates and UI jinja2>=3.1.2 aiofiles>=23.2.1