Implement OAuth login with Authentik integration, update user management, and enhance team functionalities

- Added OAuth login functionality using Authentik, allowing users to log in via OpenID.
- Updated user registration and login processes to handle OAuth users.
- Enhanced team management features, including joining and leaving teams, and displaying user-specific team information.
- Improved error handling and user feedback for team actions.
- Added new database migrations for OAuth-related fields in the users table.
- Updated templates to reflect changes in user authentication and team management.
- Refactored dashboard and leaderboard views to include user context and team memberships.
This commit is contained in:
Christian Krakau-Louis
2025-04-14 05:35:51 +02:00
parent fa41b24eac
commit 690065f788
15 changed files with 938 additions and 139 deletions
+98
View File
@@ -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()
+87
View File
@@ -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
+46 -5
View File
@@ -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)
+18 -5
View File
@@ -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"<User {self.username}>"
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):
-8
View File
@@ -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
+1 -1
View File
@@ -78,7 +78,7 @@
<p class="text-center text-gray-600 mb-4">Or sign in with</p>
<div class="flex justify-center">
<a href="/auth/oauth-login" class="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full flex items-center justify-center">
<i class="fas fa-sign-in-alt mr-2"></i> {{ oauth_provider_name }}
<i class="fas fa-sign-in-alt mr-2"></i> Authentik
</a>
</div>
</div>
+30
View File
@@ -50,6 +50,36 @@
{% endif %}
</div>
<!-- Teams Section -->
<div class="bg-white p-6 rounded-lg shadow-md">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-semibold text-irish-green">Your Teams</h2>
<a href="/teams" class="text-irish-green hover:underline">View All</a>
</div>
{% if user_teams %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for team in user_teams %}
<div class="border rounded-lg p-4 hover:bg-gray-50">
<h3 class="font-medium mb-2">{{ team.name }}</h3>
<div class="flex justify-between text-sm">
<span>Rank: #{{ team.rank|default('N/A') }}</span>
<span>{{ team.points|default(0) }} pts</span>
</div>
<a href="/teams/{{ team.id }}" class="block w-full bg-irish-green bg-opacity-10 text-irish-green text-center mt-3 py-1 rounded">
View Team
</a>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-gray-500 mb-4">You haven't joined any teams yet.</p>
<a href="/teams" class="inline-block bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition">
Join a Team
</a>
{% endif %}
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-white p-6 rounded-lg shadow-md">
+55 -23
View File
@@ -16,9 +16,21 @@
</div>
</div>
<div>
<button class="bg-white text-irish-green font-medium py-2 px-4 rounded-md hover:bg-opacity-90">
<i class="fas fa-share-alt mr-1"></i> Share Team
</button>
{% if not user %}
<a href="/auth/login?next=/teams/{{ team.id }}" class="bg-white text-irish-green font-medium py-2 px-4 rounded-md hover:bg-opacity-90">
Login to Join
</a>
{% elif not is_team_member %}
<form action="/teams/join/{{ team.id }}" method="post" class="inline">
<button type="submit" class="bg-white text-irish-green font-medium py-2 px-4 rounded-md hover:bg-opacity-90">
Join Team
</button>
</form>
{% else %}
<button class="bg-white text-irish-green font-medium py-2 px-4 rounded-md hover:bg-opacity-90">
<i class="fas fa-share-alt mr-1"></i> Share Team
</button>
{% endif %}
</div>
</div>
</div>
@@ -156,34 +168,54 @@
</div>
<!-- Team Management -->
{% if is_user_admin %}
<div class="bg-white rounded-lg shadow-md p-6">
<h2 class="text-xl font-semibold text-irish-green mb-4">Team Management</h2>
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Team Name</label>
<div class="flex">
<input type="text" value="{{ team.name }}" class="flex-grow border border-gray-300 rounded-l-md px-3 py-2" {% if not is_user_admin %}disabled{% endif %}>
<button class="bg-irish-green text-white px-4 py-2 rounded-r-md" {% if not is_user_admin %}disabled{% endif %}>Save</button>
{% if is_user_owner %}
<div class="bg-irish-green bg-opacity-10 p-3 rounded-md mb-4">
<p class="text-irish-green font-medium">You are the owner of this team</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Team Privacy</label>
<div class="flex items-center">
<input type="checkbox" id="public-team" name="is_public" class="mr-2"
{% if team.is_public %}checked{% endif %}
{% if not is_user_admin %}disabled{% endif %}>
<label for="public-team">Make team publicly joinable (no invitation needed)</label>
{% elif is_user_admin %}
<div class="bg-golden-ale bg-opacity-10 p-3 rounded-md mb-4">
<p class="text-golden-ale font-medium">You are an admin of this team</p>
</div>
</div>
{% endif %}
<div class="pt-4 border-t">
<button class="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md">
Leave Team
</button>
</div>
<form action="/teams/{{ team.id }}/update" method="post">
<div class="mb-4">
<label for="team_name" class="block text-sm font-medium text-gray-700 mb-2">Team Name</label>
<div class="flex">
<input type="text" id="team_name" name="team_name" value="{{ team.name }}" class="flex-grow border border-gray-300 rounded-l-md px-3 py-2">
<button type="submit" class="bg-irish-green text-white px-4 py-2 rounded-r-md">Save</button>
</div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Team Privacy</label>
<div class="flex items-center">
<input type="checkbox" id="is_public" name="is_public" class="mr-2"
{% if team.is_public %}checked{% endif %}>
<label for="is_public">Make team publicly joinable (no invitation needed)</label>
</div>
</div>
</form>
</div>
</div>
{% endif %}
<!-- Leave team option - only for members -->
{% if is_team_member and not is_user_owner %}
<div class="bg-white rounded-lg shadow-md p-6 mt-6">
<h2 class="text-xl font-semibold text-irish-green mb-4">Team Membership</h2>
<p class="mb-4">You are currently a member of this team.</p>
<form action="/teams/{{ team.id }}/leave" method="post">
<button type="submit" class="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md">
Leave Team
</button>
</form>
</div>
{% endif %}
</div>
{% endblock %}
+73 -20
View File
@@ -1,6 +1,13 @@
{% extends "base.html" %}
{% block content %}
<h2 class="text-2xl font-bold mb-6">Teams</h2>
{% if error %}
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-6" role="alert">
<span class="block sm:inline">{{ error }}</span>
</div>
{% endif %}
<div class="grid md:grid-cols-2 gap-6">
<div class="bg-white p-6 rounded-lg shadow-md">
<h3 class="text-xl font-semibold mb-4" style="color: var(--irish-green);">Available Teams</h3>
@@ -8,14 +15,26 @@
<ul class="space-y-3">
{% for team in teams %}
<li class="border-b pb-2 flex justify-between items-center">
<span class="font-medium">{{ team.name }}</span>
<form action="/teams/join/{{ team.id }}" method="post" class="inline">
<button type="submit"
class="px-3 py-1 text-sm rounded-md"
style="background-color: var(--golden-ale); color: var(--black-stout);">
Join Team
</button>
</form>
<a href="/teams/{{ team.id }}" class="font-medium hover:text-irish-green">{{ team.name }}</a>
{% if user %}
{% if team.id in user_team_ids %}
<span class="px-3 py-1 text-sm rounded-md bg-gray-200 text-gray-800">Member</span>
{% else %}
<form action="/teams/join/{{ team.id }}" method="post" class="inline">
<button type="submit"
class="px-3 py-1 text-sm rounded-md"
style="background-color: var(--golden-ale); color: var(--black-stout);">
Join Team
</button>
</form>
{% endif %}
{% else %}
<a href="/auth/login?next=/teams"
class="px-3 py-1 text-sm rounded-md"
style="background-color: var(--cream-white); color: var(--irish-green); border: 1px solid var(--irish-green);">
Login to Join
</a>
{% endif %}
</li>
{% endfor %}
</ul>
@@ -26,22 +45,56 @@
<div class="bg-white p-6 rounded-lg shadow-md">
<h3 class="text-xl font-semibold mb-4" style="color: var(--irish-green);">Create New Team</h3>
<form action="/teams/create" method="post" class="mt-4">
<div class="mb-4">
<label for="name" class="block text-sm font-medium mb-1">Team Name</label>
<input type="text" name="name" id="name" placeholder="Enter team name" required
class="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
style="border-color: var(--irish-green); focus:ring-color: var(--irish-green);">
{% if user %}
<form action="/teams/create" method="post" class="mt-4">
<div class="mb-4">
<label for="name" class="block text-sm font-medium mb-1">Team Name</label>
<input type="text" name="name" id="name" placeholder="Enter team name" required
class="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
style="border-color: var(--irish-green); focus:ring-color: var(--irish-green);">
</div>
<button type="submit"
class="w-full px-4 py-2 text-white rounded-md font-medium"
style="background-color: var(--irish-green);">
Create Team
</button>
</form>
{% else %}
<div class="p-4 bg-gray-100 rounded-md">
<p class="text-center mb-2">You need to be logged in to create a team</p>
<a href="/auth/login"
class="block w-full text-center px-4 py-2 text-white rounded-md font-medium"
style="background-color: var(--irish-green);">
Log In
</a>
</div>
<button type="submit"
class="w-full px-4 py-2 text-white rounded-md font-medium"
style="background-color: var(--irish-green);">
Create Team
</button>
</form>
{% endif %}
</div>
</div>
{% if user and user_team_ids %}
<div class="mt-8">
<h2 class="text-2xl font-bold mb-4">Your Teams</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for team in teams %}
{% if team.id in user_team_ids %}
<div class="bg-white p-6 rounded-lg shadow-md">
<h3 class="text-xl font-semibold mb-2" style="color: var(--irish-green);">{{ team.name }}</h3>
<div class="mb-4">
<span class="text-gray-600">{{ team.description|default("No description available", true)|truncate(120) }}</span>
</div>
<a href="/teams/{{ team.id }}"
class="block text-center w-full px-4 py-2 text-white rounded-md font-medium"
style="background-color: var(--irish-green);">
View Team
</a>
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
<div class="mt-10 bg-white p-6 rounded-lg shadow-md">
<h3 class="text-xl font-semibold mb-4" style="color: var(--irish-green);">About Teams</h3>
<p class="mb-4">
+208
View File
@@ -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}
)
+38 -15
View File
@@ -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"):
+9 -2
View File
@@ -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
}
)
+77 -11
View File
@@ -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
})
+195 -49
View File
@@ -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)
+3
View File
@@ -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