Add comprehensive documentation for LeagueLedger

- Created architecture overview in development/architecture.md
- Added installation guide in getting-started/installation.md
- Developed user guide with detailed instructions in user-guide/overview.md, user-guide/teams.md, user-guide/qr-codes.md
- Implemented social login setup documentation in social_login_setup.md
- Updated index.md to include links to new documentation sections
- Configured mkdocs.yml for site structure and theme
- Added requirements.txt for documentation dependencies
This commit is contained in:
Christian Krakau-Louis
2025-04-15 12:32:03 +02:00
parent 7323c12168
commit 6306abf6d9
26 changed files with 3350 additions and 132 deletions
+22
View File
@@ -0,0 +1,22 @@
# .readthedocs.yaml
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the OS, Python version and other tools you might need
build:
os: ubuntu-22.04
tools:
python: "3.11"
# Build documentation in the docs/ directory with MkDocs
mkdocs:
configuration: mkdocs.yml
fail_on_warning: false
# Optionally declare the Python requirements required to build your docs
python:
install:
- requirements: docs/requirements.txt
+74
View File
@@ -0,0 +1,74 @@
from starlette.authentication import (
AuthCredentials, AuthenticationBackend, UnauthenticatedUser
)
from sqlalchemy.orm import Session
from ..db import SessionLocal
from ..models import User
class SessionAuthBackend(AuthenticationBackend):
"""
Authentication backend that uses session data to authenticate users.
This maintains compatibility with the existing session-based authentication
while providing the structure of Starlette's authentication system.
"""
async def authenticate(self, request):
"""
Authenticate the user from the session.
Args:
request: The FastAPI/Starlette request object.
Returns:
Tuple of (AuthCredentials, User) if authenticated,
or None if not authenticated.
"""
# Check for user_id in session
user_id = request.session.get("user_id")
if not user_id:
# Return None to indicate no authentication
return None
# Get database connection
db = SessionLocal()
try:
# Fetch user from database
user = db.query(User).filter(User.id == user_id).first()
# If user exists, set credentials and return user
if user:
# Base credentials for all authenticated users
scopes = ["authenticated"]
# Add admin scope if user is admin
if user.is_admin:
scopes.append("admin")
# Add verified scope if user is verified
if user.is_verified:
scopes.append("verified")
# Add OAuth provider scope if it exists
# This allows policies to be set based on authentication source
oauth_provider = request.session.get("oauth_provider")
if oauth_provider:
scopes.append(f"oauth:{oauth_provider}")
# Return credentials and user
return AuthCredentials(scopes), user
finally:
db.close()
# If we get here, user not found but session exists
# Clear session on next request (handled in middleware)
return None
def on_auth_error(request, exc):
"""Handle authentication errors by redirecting to login"""
from fastapi.responses import RedirectResponse
# Build the redirect URL with the original requested path as 'next'
login_url = f"/auth/login?next={request.url.path}"
# Return redirect response
return RedirectResponse(url=login_url, status_code=303)
+802 -9
View File
@@ -1,22 +1,119 @@
import os import os
from abc import ABC, abstractmethod
from httpx_oauth.clients.google import GoogleOAuth2
from httpx_oauth.clients.github import GitHubOAuth2
from httpx_oauth.clients.facebook import FacebookOAuth2
from httpx_oauth.clients.discord import DiscordOAuth2
# LinkedIn OAuth client
from httpx_oauth.clients.linkedin import LinkedInOAuth2
# Microsoft client import may not be available in all httpx_oauth versions
try:
from httpx_oauth.clients.microsoft import MicrosoftOAuth2
MICROSOFT_AVAILABLE = True
except ImportError:
# Custom implementation if the package doesn't have it
from httpx_oauth.oauth2 import OAuth2, GetAccessTokenError
MICROSOFT_AVAILABLE = False
# Basic Microsoft OAuth2 implementation if not available in the library
class MicrosoftOAuth2(OAuth2):
def __init__(
self,
client_id: str,
client_secret: str,
tenant: str = "common",
):
super().__init__(
client_id=client_id,
client_secret=client_secret,
authorize_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
access_token_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
refresh_token_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
base_scopes=["openid", "profile", "email"],
)
from httpx_oauth.clients.openid import OpenID from httpx_oauth.clients.openid import OpenID
from httpx_oauth.oauth2 import GetAccessTokenError from httpx_oauth.oauth2 import GetAccessTokenError
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, List, Type
import json import json
import httpx import httpx
from urllib.parse import urlencode from urllib.parse import urlencode
class AuthentikOAuth: class OAuthProvider(ABC):
"""Base class for all OAuth providers"""
# Provider identifier - should be unique and lowercase
provider_id = "base"
# Display name for UI
display_name = "Base Provider"
# Icon class (for UI rendering, e.g. Font Awesome)
icon_class = "fas fa-sign-in-alt"
# Default button color (hex or valid CSS color name)
button_color = "#333333"
def __init__(self): 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.client = None
self.initialize_client() self.initialize_client()
@abstractmethod
def initialize_client(self):
"""Initialize the specific OAuth client"""
pass
@abstractmethod
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
"""Get the authorization URL for this provider"""
pass
@abstractmethod
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
"""Get user information from the provider"""
pass
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
"""
Normalize provider-specific user data into a standard format
Returns:
Dict with standard fields:
- id: Unique identifier from provider
- email: User email (if available)
- name: User's full/display name
- first_name: User's first name (if available)
- last_name: User's last name (if available)
- picture: URL to user's avatar/picture (if available)
- raw: The original user_info dict
"""
# Default implementation - should be overridden by providers
return {
"id": str(user_info.get("id", "")),
"email": user_info.get("email"),
"name": user_info.get("name"),
"first_name": user_info.get("given_name"),
"last_name": user_info.get("family_name"),
"picture": user_info.get("picture"),
"raw": user_info
}
class AuthentikOAuth(OAuthProvider):
"""Authentik OpenID Connect OAuth provider"""
provider_id = "authentik"
display_name = "Authentik"
icon_class = "fas fa-shield-alt"
button_color = "#fd4b2d"
def __init__(self):
self.client_id = os.getenv("AUTHENTIK_CLIENT_ID", "yourAuthentikClientID")
self.client_secret = os.getenv("AUTHENTIK_CLIENT_SECRET", "yourAuthentikClientSecret")
self.config_url = os.getenv("AUTHENTIK_CONFIG_URL",
"https://authentik.example.com/application/o/leagueledger/.well-known/openid-configuration")
super().__init__()
def initialize_client(self): def initialize_client(self):
try: try:
@@ -94,5 +191,701 @@ class AuthentikOAuth:
print(f"Error getting user info: {str(e)}") print(f"Error getting user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
# Instantiate the OAuth client for the application to use class GoogleOAuth(OAuthProvider):
authentik_oauth = AuthentikOAuth() """Google OAuth provider implementation"""
provider_id = "google"
display_name = "Google"
icon_class = "fab fa-google"
button_color = "#4285F4"
def __init__(self):
self.client_id = os.getenv("GOOGLE_CLIENT_ID", "")
self.client_secret = os.getenv("GOOGLE_CLIENT_SECRET", "")
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
self.client = GoogleOAuth2(
client_id=self.client_id,
client_secret=self.client_secret
)
except Exception as e:
print(f"Error initializing Google 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="Google 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", "")),
extras_params={"access_type": "offline", "prompt": "select_account"}
)
return authorization_url
except Exception as e:
print(f"Error getting Google 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="Google 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 Google access token")
# Get user info from Google
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(
"https://www.googleapis.com/oauth2/v3/userinfo",
headers=headers
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching Google user info: {response.text}")
return response.json()
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"Google OAuth error: {error_description}")
except Exception as e:
print(f"Error getting Google user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
# Google specific normalization
return {
"id": user_info.get("sub", ""),
"email": user_info.get("email"),
"name": user_info.get("name"),
"first_name": user_info.get("given_name"),
"last_name": user_info.get("family_name"),
"picture": user_info.get("picture"),
"raw": user_info
}
class GitHubOAuth(OAuthProvider):
"""GitHub OAuth provider implementation"""
provider_id = "github"
display_name = "GitHub"
icon_class = "fab fa-github"
button_color = "#171515"
def __init__(self):
self.client_id = os.getenv("GITHUB_CLIENT_ID", "")
self.client_secret = os.getenv("GITHUB_CLIENT_SECRET", "")
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
self.client = GitHubOAuth2(
client_id=self.client_id,
client_secret=self.client_secret
)
except Exception as e:
print(f"Error initializing GitHub 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="GitHub OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
scope=["user:email"],
state=str(request.session.get("session_id", "")),
)
return authorization_url
except Exception as e:
print(f"Error getting GitHub 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="GitHub 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 GitHub access token")
# Get user info from GitHub
user_data = {}
async with httpx.AsyncClient() as client:
headers = {
"Authorization": f"token {access_token}",
"Accept": "application/vnd.github.v3+json"
}
# Get user profile
user_response = await client.get(
"https://api.github.com/user",
headers=headers
)
if user_response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching GitHub user info: {user_response.text}")
user_data = user_response.json()
# Get user emails
email_response = await client.get(
"https://api.github.com/user/emails",
headers=headers
)
if email_response.status_code == 200:
emails = email_response.json()
primary_email = next((email for email in emails if email.get("primary") is True), None)
if primary_email:
user_data["email"] = primary_email.get("email")
return user_data
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"GitHub OAuth error: {error_description}")
except Exception as e:
print(f"Error getting GitHub user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
# GitHub specific normalization
name_parts = (user_info.get("name") or "").split(" ", 1)
first_name = name_parts[0] if name_parts else ""
last_name = name_parts[1] if len(name_parts) > 1 else ""
return {
"id": str(user_info.get("id", "")),
"email": user_info.get("email"),
"name": user_info.get("name") or user_info.get("login"),
"first_name": first_name,
"last_name": last_name,
"picture": user_info.get("avatar_url"),
"raw": user_info
}
class FacebookOAuth(OAuthProvider):
"""Facebook OAuth provider implementation"""
provider_id = "facebook"
display_name = "Facebook"
icon_class = "fab fa-facebook"
button_color = "#1877F2"
def __init__(self):
self.client_id = os.getenv("FACEBOOK_CLIENT_ID", "")
self.client_secret = os.getenv("FACEBOOK_CLIENT_SECRET", "")
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
self.client = FacebookOAuth2(
client_id=self.client_id,
client_secret=self.client_secret
)
except Exception as e:
print(f"Error initializing Facebook 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="Facebook OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
scope=["email", "public_profile"],
state=str(request.session.get("session_id", ""))
)
return authorization_url
except Exception as e:
print(f"Error getting Facebook 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="Facebook 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 Facebook access token")
# Get user info from Facebook
async with httpx.AsyncClient() as client:
response = await client.get(
"https://graph.facebook.com/me",
params={
"access_token": access_token,
"fields": "id,name,email,first_name,last_name,picture"
}
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching Facebook user info: {response.text}")
return response.json()
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"Facebook OAuth error: {error_description}")
except Exception as e:
print(f"Error getting Facebook user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
# Facebook specific normalization
picture_url = None
if "picture" in user_info and "data" in user_info["picture"]:
picture_url = user_info["picture"]["data"].get("url")
return {
"id": user_info.get("id", ""),
"email": user_info.get("email"),
"name": user_info.get("name"),
"first_name": user_info.get("first_name"),
"last_name": user_info.get("last_name"),
"picture": picture_url,
"raw": user_info
}
class MicrosoftOAuth(OAuthProvider):
"""Microsoft OAuth provider implementation"""
provider_id = "microsoft"
display_name = "Microsoft"
icon_class = "fab fa-microsoft"
button_color = "#00A4EF"
def __init__(self):
self.client_id = os.getenv("MICROSOFT_CLIENT_ID", "")
self.client_secret = os.getenv("MICROSOFT_CLIENT_SECRET", "")
self.tenant = os.getenv("MICROSOFT_TENANT", "common") # "common" for multi-tenant apps
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
self.client = MicrosoftOAuth2(
client_id=self.client_id,
client_secret=self.client_secret,
tenant=self.tenant
)
except Exception as e:
print(f"Error initializing Microsoft 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="Microsoft OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
scope=["User.Read", "email", "profile", "openid"],
state=str(request.session.get("session_id", ""))
)
return authorization_url
except Exception as e:
print(f"Error getting Microsoft 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="Microsoft 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 Microsoft access token")
# Get user info from Microsoft Graph API
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(
"https://graph.microsoft.com/v1.0/me",
headers=headers
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching Microsoft user info: {response.text}")
return response.json()
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"Microsoft OAuth error: {error_description}")
except Exception as e:
print(f"Error getting Microsoft user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
return {
"id": user_info.get("id", ""),
"email": user_info.get("mail") or user_info.get("userPrincipalName"),
"name": user_info.get("displayName"),
"first_name": user_info.get("givenName"),
"last_name": user_info.get("surname"),
"picture": None, # Microsoft Graph doesn't include photo in basic profile
"raw": user_info
}
class DiscordOAuth(OAuthProvider):
"""Discord OAuth provider implementation"""
provider_id = "discord"
display_name = "Discord"
icon_class = "fab fa-discord"
button_color = "#5865F2"
def __init__(self):
self.client_id = os.getenv("DISCORD_CLIENT_ID", "")
self.client_secret = os.getenv("DISCORD_CLIENT_SECRET", "")
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
self.client = DiscordOAuth2(
client_id=self.client_id,
client_secret=self.client_secret
)
except Exception as e:
print(f"Error initializing Discord 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="Discord OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
scope=["identify", "email"],
state=str(request.session.get("session_id", ""))
)
return authorization_url
except Exception as e:
print(f"Error getting Discord 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="Discord 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 Discord access token")
# Get user info from Discord API
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(
"https://discord.com/api/users/@me",
headers=headers
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching Discord user info: {response.text}")
return response.json()
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"Discord OAuth error: {error_description}")
except Exception as e:
print(f"Error getting Discord user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
avatar_url = None
if user_info.get("avatar"):
user_id = user_info.get("id")
avatar_hash = user_info.get("avatar")
avatar_url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png"
# Discord doesn't split names, just has a username
return {
"id": user_info.get("id", ""),
"email": user_info.get("email"),
"name": user_info.get("username") or user_info.get("global_name"),
"first_name": user_info.get("username", "").split("#", 1)[0],
"last_name": "",
"picture": avatar_url,
"raw": user_info
}
class LinkedInOAuth(OAuthProvider):
"""LinkedIn OAuth provider implementation using OpenID Connect"""
provider_id = "linkedin"
display_name = "LinkedIn"
icon_class = "fab fa-linkedin"
button_color = "#0077B5"
# LinkedIn OIDC endpoints
AUTHORIZATION_URL = "https://www.linkedin.com/oauth/v2/authorization"
TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
USERINFO_URL = "https://api.linkedin.com/v2/userinfo"
def __init__(self):
self.client_id = os.getenv("LINKEDIN_CLIENT_ID", "")
self.client_secret = os.getenv("LINKEDIN_CLIENT_SECRET", "")
super().__init__()
def initialize_client(self):
if not self.client_id or not self.client_secret:
self.client = None
return
try:
# Create a custom OAuth2 client for LinkedIn OpenID Connect
from httpx_oauth.oauth2 import OAuth2
self.client = OAuth2(
client_id=self.client_id,
client_secret=self.client_secret,
authorize_endpoint=self.AUTHORIZATION_URL,
access_token_endpoint=self.TOKEN_URL,
refresh_token_endpoint=self.TOKEN_URL,
base_scopes=["openid", "profile", "email"]
)
except Exception as e:
print(f"Error initializing LinkedIn 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="LinkedIn OAuth client could not be initialized")
try:
authorization_url = await self.client.get_authorization_url(
redirect_uri=redirect_uri,
# Using the required OpenID Connect scopes
scope=["openid", "profile", "email"],
state=str(request.session.get("session_id", ""))
)
return authorization_url
except Exception as e:
print(f"Error getting LinkedIn 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="LinkedIn 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")
id_token = token.get("id_token") # JWT token containing basic user info
if not access_token:
raise HTTPException(status_code=400, detail="Could not get LinkedIn access token")
# Get user info from LinkedIn UserInfo endpoint (OIDC standard endpoint)
async with httpx.AsyncClient() as client:
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.get(
self.USERINFO_URL,
headers=headers
)
if response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Error fetching LinkedIn user info: {response.text}")
user_info = response.json()
# The user info from the OIDC userinfo endpoint should already contain the
# email if requested in the scope, no need for a separate call
return user_info
except GetAccessTokenError as e:
error_description = e.args[0]
raise HTTPException(status_code=400, detail=f"LinkedIn OAuth error: {error_description}")
except Exception as e:
print(f"Error getting LinkedIn user info: {str(e)}")
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
# LinkedIn OpenID Connect response normalization
return {
"id": user_info.get("sub", ""), # 'sub' is the standard OIDC subject identifier
"email": user_info.get("email"),
"name": user_info.get("name"),
"first_name": user_info.get("given_name"),
"last_name": user_info.get("family_name"),
"picture": user_info.get("picture"),
"raw": user_info
}
class OAuthManager:
"""
Manager class for handling multiple OAuth providers
"""
def __init__(self):
self.providers: Dict[str, OAuthProvider] = {}
self.register_default_providers()
def register_default_providers(self):
"""Register all available providers"""
self.register_provider(AuthentikOAuth())
self.register_provider(GoogleOAuth())
self.register_provider(FacebookOAuth())
self.register_provider(GitHubOAuth())
self.register_provider(MicrosoftOAuth())
self.register_provider(DiscordOAuth())
self.register_provider(LinkedInOAuth())
def register_provider(self, provider: OAuthProvider):
"""Register a new provider"""
self.providers[provider.provider_id] = provider
def get_provider(self, provider_id: str) -> Optional[OAuthProvider]:
"""Get a provider by ID"""
return self.providers.get(provider_id)
def get_available_providers(self) -> List[Dict[str, Any]]:
"""
Get list of available providers (those with credentials configured)
Returns a list of provider details for UI rendering
"""
available_providers = []
for provider_id, provider in self.providers.items():
if provider.client is not None:
available_providers.append({
"id": provider.provider_id,
"name": provider.display_name,
"icon": provider.icon_class,
"color": provider.button_color
})
return available_providers
async def get_login_url(self, request: Request, provider_id: str, redirect_uri: str) -> str:
"""Get login URL for a specific provider"""
provider = self.get_provider(provider_id)
if not provider:
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_id}")
return await provider.get_login_url(request, redirect_uri)
async def get_user_info(self, request: Request, provider_id: str,
redirect_uri: str, code: str) -> Dict[str, Any]:
"""Get user info from a specific provider"""
provider = self.get_provider(provider_id)
if not provider:
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_id}")
# Get raw user info
user_info = await provider.get_user_info(request, redirect_uri, code)
# Normalize it
return provider.get_normalized_user_data(user_info)
# Create the OAuth manager for the application to use
oauth_manager = OAuthManager()
# For backwards compatibility
authentik_oauth = oauth_manager.get_provider("authentik")
+137
View File
@@ -0,0 +1,137 @@
from functools import wraps
from typing import List, Optional, Callable, Union
from starlette.authentication import requires
from fastapi import Request, HTTPException, status
from fastapi.responses import RedirectResponse
def require_auth(redirect_url: Optional[str] = None):
"""
Decorator to require authentication for FastAPI route handlers.
This uses Starlette's requires decorator with the "authenticated" scope.
Args:
redirect_url: URL to redirect to if user is not authenticated (optional).
If not provided, returns a 401 Unauthorized error.
Example:
@router.get("/protected")
@require_auth(redirect_url="/auth/login")
async def protected_route(request: Request):
return {"user": request.user.display_name}
"""
return requires(
"authenticated",
status_code=status.HTTP_401_UNAUTHORIZED,
redirect=redirect_url
)
def require_admin(redirect_url: Optional[str] = None):
"""
Decorator to require admin privileges for FastAPI route handlers.
This uses Starlette's requires decorator with the "admin" scope.
Args:
redirect_url: URL to redirect to if user is not an admin (optional).
If not provided, returns a 403 Forbidden error.
Example:
@router.get("/admin-only")
@require_admin(redirect_url="/auth/login")
async def admin_only_route(request: Request):
return {"message": "You are an admin"}
"""
return requires(
"admin",
status_code=status.HTTP_403_FORBIDDEN,
redirect=redirect_url
)
def require_verified(redirect_url: Optional[str] = None):
"""
Decorator to require verified users for FastAPI route handlers.
This uses Starlette's requires decorator with the "verified" scope.
Args:
redirect_url: URL to redirect to if user is not verified (optional).
If not provided, returns a 403 Forbidden error.
Example:
@router.get("/verified-only")
@require_verified(redirect_url="/auth/verify-email")
async def verified_only_route(request: Request):
return {"message": "Your email is verified"}
"""
return requires(
"verified",
status_code=status.HTTP_403_FORBIDDEN,
redirect=redirect_url
)
def require_team_captain(team_id_param: str = "team_id"):
"""
Decorator to require team captain privileges for a specific team.
This decorator doesn't use Starlette's requires as it needs access to
path parameters and the database to check team captain status.
Args:
team_id_param: Name of the path parameter that contains the team ID.
Example:
@router.get("/teams/{team_id}/manage")
@require_team_captain()
async def manage_team(request: Request, team_id: int):
return {"message": f"You are the captain of team {team_id}"}
"""
def decorator(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
request = kwargs.get("request") or next(
(arg for arg in args if isinstance(arg, Request)), None
)
if not request:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Request object not found in endpoint arguments"
)
# Check if user is authenticated
if not request.user.is_authenticated:
return RedirectResponse(
url=f"/auth/login?next={request.url.path}",
status_code=status.HTTP_303_SEE_OTHER
)
# Get team_id from path parameters
team_id = kwargs.get(team_id_param)
if not team_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Team ID parameter '{team_id_param}' not found"
)
# Get database session
from sqlalchemy.orm import Session
from ..db import SessionLocal
from ..models import TeamMembership
db = SessionLocal()
try:
# Check if user is team captain
membership = db.query(TeamMembership).filter(
TeamMembership.team_id == team_id,
TeamMembership.user_id == int(request.user.identity),
TeamMembership.is_captain == True
).first()
if not membership:
return RedirectResponse(
url=f"/teams/{team_id}",
status_code=status.HTTP_303_SEE_OTHER
)
finally:
db.close()
return await func(*args, **kwargs)
return wrapper
return decorator
+11 -2
View File
@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
from passlib.context import CryptContext from passlib.context import CryptContext
from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event
from .db import SessionLocal from .db import SessionLocal, engine
from .db_migrations import run_migrations
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
@@ -25,6 +26,14 @@ def table_has_column(engine, table_name, column_name):
columns = [col['name'] for col in inspector.get_columns(table_name)] columns = [col['name'] for col in inspector.get_columns(table_name)]
return column_name in columns return column_name in columns
def init_db():
"""Initialize the database, applying migrations and seeding data."""
# First, run any needed migrations
run_migrations(engine)
# Then proceed with seeding if needed
seed_db()
def seed_db(): def seed_db():
"""Seed the database with test data.""" """Seed the database with test data."""
db = SessionLocal() db = SessionLocal()
@@ -288,4 +297,4 @@ def seed_db():
db.close() db.close()
if __name__ == "__main__": if __name__ == "__main__":
seed_db() init_db()
+86 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import text, inspect from sqlalchemy import text, inspect, Column, String, JSON, MetaData, Table
from .db import engine from .db import engine
def table_exists(conn, table_name): def table_exists(conn, table_name):
@@ -90,3 +90,88 @@ def apply_migrations():
except Exception as e: except Exception as e:
print(f"Error applying migrations: {str(e)}") print(f"Error applying migrations: {str(e)}")
def run_migrations(engine):
"""
Run database migrations that can't be handled by SQLAlchemy's create_all()
"""
# Create a MetaData object
metadata = MetaData()
metadata.bind = engine
connection = engine.connect()
try:
print("Running migrations...")
# Check if the columns already exist before adding them
# Add additional_oauth_providers column if it doesn't exist
add_oauth_providers_column(connection)
# Add first_name and last_name columns if they don't exist
add_name_columns(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:
# Use database-agnostic way to check if column exists
inspector = inspect(engine)
columns = [col['name'] for col in inspector.get_columns('users')]
if 'additional_oauth_providers' not in columns:
print("Adding additional_oauth_providers column to users table")
# Add column with database-specific syntax
if engine.name == 'sqlite':
connection.execute(text("""
ALTER TABLE users
ADD COLUMN additional_oauth_providers JSON
"""))
else: # MySQL
connection.execute(text("""
ALTER TABLE users
ADD COLUMN additional_oauth_providers JSON NULL
"""))
connection.commit()
else:
print("Column additional_oauth_providers already exists")
except Exception as e:
print(f"Error adding additional_oauth_providers column: {str(e)}")
def add_name_columns(connection):
"""Add first_name and last_name columns to users table"""
try:
# Use database-agnostic way to check if columns exist
inspector = inspect(engine)
columns = [col['name'] for col in inspector.get_columns('users')]
# Add first_name if needed
if 'first_name' not in columns:
print("Adding first_name column to users table")
connection.execute(text("""
ALTER TABLE users
ADD COLUMN first_name VARCHAR(50) NULL
"""))
connection.commit()
else:
print("Column first_name already exists")
# Add last_name if needed
if 'last_name' not in columns:
print("Adding last_name column to users table")
connection.execute(text("""
ALTER TABLE users
ADD COLUMN last_name VARCHAR(50) NULL
"""))
connection.commit()
else:
print("Column last_name already exists")
except Exception as e:
print(f"Error adding name columns: {str(e)}")
+17 -16
View File
@@ -7,16 +7,17 @@ from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path from pathlib import Path
import os import os
from starlette.middleware.sessions import SessionMiddleware from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.authentication import AuthenticationMiddleware
from dotenv import load_dotenv from dotenv import load_dotenv
import logging import logging
import contextlib import contextlib
from .db import init_db, engine, get_db from .db import engine, get_db
from . import models from . import models
from .templates_config import templates from .templates_config import templates
from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience
from .db_init import seed_db from .db_init import init_db
from .db_migrations import apply_migrations from .auth.middleware import SessionAuthBackend, on_auth_error
# Configure logging # Configure logging
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -37,8 +38,18 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Add SessionMiddleware with a secure secret key # Get secret key for the session
SECRET_KEY = os.getenv("SECRET_KEY", "a-very-secure-secret-key-for-development") SECRET_KEY = os.getenv("SECRET_KEY", "a-very-secure-secret-key-for-development")
# Important: Order of middleware matters!
# First add AuthenticationMiddleware
app.add_middleware(
AuthenticationMiddleware,
backend=SessionAuthBackend(),
on_error=on_auth_error
)
# Then add SessionMiddleware (last added = first executed)
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY) app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
# Initialize database on startup # Initialize database on startup
@@ -49,19 +60,9 @@ async def startup_db_client():
# Import models to ensure they're registered with Base before initialization # Import models to ensure they're registered with Base before initialization
from . import models from . import models
# Create all tables first # Initialize database (applies migrations and seeds data)
init_db() init_db()
logger.info("Base tables created successfully") logger.info("Database initialized and migrated successfully")
# Apply migrations to add additional columns and constraints
with contextlib.suppress(Exception):
apply_migrations()
logger.info("Migrations applied successfully")
# Seed the database with test data if needed
with contextlib.suppress(Exception):
seed_db()
logger.info("Database seeded successfully")
except Exception as e: except Exception as e:
logger.error(f"Database initialization error: {str(e)}") logger.error(f"Database initialization error: {str(e)}")
+27 -3
View File
@@ -1,14 +1,15 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float, UniqueConstraint from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float, UniqueConstraint, JSON
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime from datetime import datetime
from starlette.authentication import BaseUser
# This Base should be the single source of truth # This Base should be the single source of truth
Base = declarative_base() Base = declarative_base()
class User(Base): class User(Base, BaseUser):
__tablename__ = "users" __tablename__ = "users"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
username = Column(String(50), unique=True, index=True, nullable=False) username = Column(String(50), unique=True, index=True, nullable=False)
@@ -29,7 +30,14 @@ class User(Base):
# OAuth fields # OAuth fields
is_oauth_user = Column(Boolean, default=False) is_oauth_user = Column(Boolean, default=False)
oauth_id = Column(String(255), nullable=True) oauth_id = Column(String(255), nullable=True)
oauth_provider = Column(String(50), nullable=True) oauth_provider = Column(String(50), nullable=True) # Primary OAuth provider
# New field for multiple providers: store as JSON {provider_name: provider_user_id}
additional_oauth_providers = Column(JSON, nullable=True)
# Profile fields
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 = Column(String(255), nullable=True) # URL to profile picture
# Relationships # Relationships
@@ -38,6 +46,22 @@ class User(Base):
events_attended = relationship("EventAttendee", back_populates="user") events_attended = relationship("EventAttendee", back_populates="user")
owned_teams = relationship("Team", back_populates="owner") owned_teams = relationship("Team", back_populates="owner")
# BaseUser interface implementation
@property
def is_authenticated(self) -> bool:
"""Return True as this user is authenticated."""
return True
@property
def display_name(self) -> str:
"""Return the display name for this user."""
return self.username
@property
def identity(self) -> str:
"""Return the identity of this user."""
return str(self.id)
def __repr__(self): def __repr__(self):
return f"<User {self.username}>" return f"<User {self.username}>"
+24 -17
View File
@@ -87,27 +87,34 @@
</div> </div>
<!-- OAuth login options --> <!-- OAuth login options -->
{% if show_oauth %} {% if oauth_providers and oauth_providers|length > 0 %}
<div class="mt-6 pt-6 border-t border-gray-200">
<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> Authentik
</a>
</div>
</div>
{% else %}
<div class="mt-6 pt-6 border-t border-gray-200"> <div class="mt-6 pt-6 border-t border-gray-200">
<p class="text-center text-gray-600 mb-4">Or sign in with</p> <p class="text-center text-gray-600 mb-4">Or sign in with</p>
<!-- For 2 or fewer providers, show them side by side -->
{% if oauth_providers|length <= 2 %}
<div class="flex justify-center space-x-4"> <div class="flex justify-center space-x-4">
<button class="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full disabled:opacity-50" disabled> {% for provider in oauth_providers %}
<i class="fab fa-google mr-2"></i> Google <a href="/auth/oauth-login/{{ provider.id }}"
</button> class="flex items-center justify-center w-full py-2 px-4 rounded-md transition"
<button class="bg-gray-800 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full disabled:opacity-50" disabled> style="background-color: {{ provider.color }}; color: white;">
<i class="fab fa-github mr-2"></i> GitHub <i class="{{ provider.icon }} mr-2"></i> {{ provider.name }}
</button> </a>
{% endfor %}
</div> </div>
<p class="text-center text-gray-500 text-xs mt-2">OAuth login currently disabled</p>
<!-- For more than 2 providers, stack them vertically -->
{% else %}
<div class="space-y-3">
{% for provider in oauth_providers %}
<a href="/auth/oauth-login/{{ provider.id }}"
class="flex items-center justify-center w-full py-2 px-4 rounded-md transition"
style="background-color: {{ provider.color }}; color: white;">
<i class="{{ provider.icon }} mr-2"></i> {{ provider.name }}
</a>
{% endfor %}
</div>
{% endif %}
</div> </div>
{% endif %} {% endif %}
</div> </div>
+31 -2
View File
@@ -1,15 +1,19 @@
""" """
Authentication utilities for LeagueLedger. 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
from fastapi import Request, Depends from fastapi import Request, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..db import get_db from ..db import get_db
from ..models import User, TeamMembership from ..models import User, TeamMembership
from starlette.authentication import UnauthenticatedUser
async def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]: async def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
""" """
Get the current authenticated user from the session. Get the current authenticated user from the request.
Now uses request.user from Starlette authentication with fallback to session.
Args: Args:
request: The FastAPI request object request: The FastAPI request object
@@ -18,6 +22,11 @@ async def get_current_user(request: Request, db: Session = Depends(get_db)) -> O
Returns: Returns:
User object if authenticated, None otherwise User object if authenticated, None otherwise
""" """
# First try to get user from Starlette authentication
if hasattr(request, "user") and request.user.is_authenticated:
return request.user
# Fallback to session-based authentication (for backward compatibility)
user_id = request.session.get("user_id") user_id = request.session.get("user_id")
if not user_id: if not user_id:
@@ -61,7 +70,7 @@ async def is_team_captain(
is_captain = db.query(TeamMembership).filter( is_captain = db.query(TeamMembership).filter(
TeamMembership.team_id == team_id, TeamMembership.team_id == team_id,
TeamMembership.user_id == user.id, TeamMembership.user_id == user.id,
TeamMembership.role == "captain" TeamMembership.is_captain == True # Changed from role="captain" to is_captain=True
).first() ).first()
return bool(is_captain) return bool(is_captain)
@@ -69,6 +78,7 @@ async def is_team_captain(
async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool: async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool:
""" """
Check if the current user is an admin. Check if the current user is an admin.
Now checks Starlette auth scopes first.
Args: Args:
request: FastAPI request object request: FastAPI request object
@@ -77,6 +87,11 @@ async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool:
Returns: Returns:
True if the user is an admin, False otherwise True if the user is an admin, False otherwise
""" """
# Check for 'admin' scope in Starlette auth
if hasattr(request, "auth") and request.auth and "admin" in request.auth.scopes:
return True
# Fallback to user object check
user = await get_current_user(request, db) user = await get_current_user(request, db)
if not user: if not user:
@@ -97,6 +112,11 @@ async def requires_login(request: Request, db: Session = Depends(get_db)) -> Opt
""" """
from fastapi import HTTPException, status from fastapi import HTTPException, status
# Check Starlette auth first
if hasattr(request, "user") and request.user.is_authenticated:
return request.user
# Fallback to session check
user = await get_current_user(request, db) user = await get_current_user(request, db)
if not user: if not user:
@@ -121,6 +141,12 @@ async def requires_admin(request: Request, db: Session = Depends(get_db)) -> Use
""" """
from fastapi import HTTPException, status from fastapi import HTTPException, status
# Check for admin scope in Starlette auth
if hasattr(request, "auth") and request.auth and "admin" in request.auth.scopes:
if hasattr(request, "user") and request.user.is_authenticated:
return request.user
# Fallback to user object check
user = await get_current_user(request, db) user = await get_current_user(request, db)
if not user or not user.is_admin: if not user or not user.is_admin:
@@ -130,3 +156,6 @@ async def requires_admin(request: Request, db: Session = Depends(get_db)) -> Use
) )
return user return user
# Note: For new code, consider using the decorators in app.auth.permissions instead
# of these dependency functions directly
+13 -44
View File
@@ -16,6 +16,7 @@ from ..models import (
OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints
) )
from ..templates_config import templates from ..templates_config import templates
from ..auth.permissions import require_admin
router = APIRouter() router = APIRouter()
@@ -74,25 +75,18 @@ def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
return relationships return relationships
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login?next=/admin/")
async def admin_home(request: Request, db: Session = Depends(get_db)): async def admin_home(request: Request, db: Session = Depends(get_db)):
"""Admin dashboard home.""" """Admin dashboard home."""
# Get user from session for navbar # Since we're using Starlette's authentication, the user is now available in request.user
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(User).get(user_id)
# Check admin status
if not user or not user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
model_list = [(key, name) for key, (_, name) in MODELS.items()] model_list = [(key, name) for key, (_, name) in MODELS.items()]
return templates.TemplateResponse( return templates.TemplateResponse(
"admin/index.html", "admin/index.html",
{"request": request, "models": model_list, "user": user} {"request": request, "models": model_list, "user": request.user}
) )
@router.get("/{model_name}", response_class=HTMLResponse) @router.get("/{model_name}", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login")
async def list_records( async def list_records(
request: Request, request: Request,
model_name: str, model_name: str,
@@ -101,16 +95,6 @@ async def list_records(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""List records for a model with pagination.""" """List records for a model with pagination."""
# 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)
# Check admin status
if not user or not user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
if model_name not in MODELS: if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found") raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
@@ -150,27 +134,18 @@ async def list_records(
"per_page": per_page, "per_page": per_page,
"total_pages": total_pages, "total_pages": total_pages,
"total_records": total_records, "total_records": total_records,
"user": user # Add user to the context "user": request.user # Add user to the context
} }
) )
@router.get("/{model_name}/new", response_class=HTMLResponse) @router.get("/{model_name}/new", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login")
async def create_record_form( async def create_record_form(
request: Request, request: Request,
model_name: str, model_name: str,
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Show form for creating a new record.""" """Show form for creating a new record."""
# 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)
# Check admin status
if not user or not user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
if model_name not in MODELS: if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found") raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
@@ -201,11 +176,12 @@ async def create_record_form(
"record": None, # No record for new form "record": None, # No record for new form
"foreign_key_options": foreign_key_options, "foreign_key_options": foreign_key_options,
"is_new": True, "is_new": True,
"user": user # Add user to the context "user": request.user # Use request.user from Starlette authentication
} }
) )
@router.post("/{model_name}/new") @router.post("/{model_name}/new")
@require_admin(redirect_url="/auth/login")
async def create_record( async def create_record(
request: Request, request: Request,
model_name: str, model_name: str,
@@ -254,6 +230,7 @@ async def create_record(
return RedirectResponse(f"/admin/{model_name}", status_code=303) return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}", response_class=HTMLResponse) @router.get("/{model_name}/{record_id}", response_class=HTMLResponse)
@require_admin(redirect_url="/auth/login")
async def edit_record_form( async def edit_record_form(
request: Request, request: Request,
model_name: str, model_name: str,
@@ -261,16 +238,6 @@ async def edit_record_form(
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Show form for editing an existing record.""" """Show form for editing an existing record."""
# 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)
# Check admin status
if not user or not user.is_admin:
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
if model_name not in MODELS: if model_name not in MODELS:
raise HTTPException(status_code=404, detail=f"Model {model_name} not found") raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
@@ -311,11 +278,12 @@ async def edit_record_form(
"record": record_data, "record": record_data,
"foreign_key_options": foreign_key_options, "foreign_key_options": foreign_key_options,
"is_new": False, "is_new": False,
"user": user # Add user to the context "user": request.user # Use request.user from Starlette authentication
} }
) )
@router.post("/{model_name}/{record_id}") @router.post("/{model_name}/{record_id}")
@require_admin(redirect_url="/auth/login")
async def update_record( async def update_record(
request: Request, request: Request,
model_name: str, model_name: str,
@@ -364,6 +332,7 @@ async def update_record(
return RedirectResponse(f"/admin/{model_name}", status_code=303) return RedirectResponse(f"/admin/{model_name}", status_code=303)
@router.get("/{model_name}/{record_id}/delete") @router.get("/{model_name}/{record_id}/delete")
@require_admin(redirect_url="/auth/login")
async def delete_record( async def delete_record(
request: Request, request: Request,
model_name: str, model_name: str,
+114 -36
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from typing import Optional from typing import Optional, List, Dict, Any
import secrets import secrets
import os import os
import uuid import uuid
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta
from ..db import get_db from ..db import get_db
from ..models import User from ..models import User
from ..auth.oauth import authentik_oauth from ..auth.oauth import oauth_manager
from ..templates_config import templates from ..templates_config import templates
from ..security import verify_password, get_password_hash from ..security import verify_password, get_password_hash
from ..utils.mail import send_password_reset_email from ..utils.mail import send_password_reset_email
@@ -31,11 +31,18 @@ async def login_page(request: Request, error: Optional[str] = None, message: Opt
if not message: if not message:
message = "You are already logged in." message = "You are already logged in."
# Get available OAuth providers for the login screen
oauth_providers = oauth_manager.get_available_providers()
return templates.TemplateResponse( return templates.TemplateResponse(
"auth/login.html", "auth/login.html",
{"request": request, "error": error, "message": message, {
"show_oauth": True, "oauth_provider_name": "Authentik", "request": request,
"user": user} # Add user to context "error": error,
"message": message,
"user": user,
"oauth_providers": oauth_providers
}
) )
@router.post("/login", response_class=HTMLResponse) @router.post("/login", response_class=HTMLResponse)
@@ -71,8 +78,7 @@ async def login_post(
{ {
"request": request, "request": request,
"error": "Please verify your email address before logging in", "error": "Please verify your email address before logging in",
"show_oauth": True, "oauth_providers": oauth_manager.get_available_providers(),
"oauth_provider_name": "Authentik",
"unverified_user_id": user.id, "unverified_user_id": user.id,
"unverified_email": user.email "unverified_email": user.email
} }
@@ -85,8 +91,7 @@ async def login_post(
{ {
"request": request, "request": request,
"error": error, "error": error,
"show_oauth": True, "oauth_providers": oauth_manager.get_available_providers()
"oauth_provider_name": "Authentik"
} }
) )
@@ -317,21 +322,21 @@ async def resend_verification(
status_code=HTTP_303_SEE_OTHER status_code=HTTP_303_SEE_OTHER
) )
@router.get("/oauth-login") @router.get("/oauth-login/{provider_id}")
async def oauth_login(request: Request): async def oauth_login(request: Request, provider_id: str):
"""Start the OAuth login flow""" """Start the OAuth login flow for the specified provider"""
# Generate the redirect URI # Generate the redirect URI
base_url = str(request.base_url) base_url = str(request.base_url)
redirect_uri = f"{base_url}auth/oauth-callback" redirect_uri = f"{base_url}auth/oauth-callback/{provider_id}"
# Request a login URL from the Authentik provider # Request a login URL from the provider
try: try:
# Set a session ID to validate the callback # Set a session ID to validate the callback
if "session_id" not in request.session: if "session_id" not in request.session:
request.session["session_id"] = str(uuid.uuid4()) request.session["session_id"] = str(uuid.uuid4())
# Get the authorization URL - make sure to await it # Get the authorization URL
auth_url = await authentik_oauth.get_login_url(request, redirect_uri) auth_url = await oauth_manager.get_login_url(request, provider_id, redirect_uri)
# Redirect to the authorization URL # Redirect to the authorization URL
return RedirectResponse(auth_url) return RedirectResponse(auth_url)
@@ -342,9 +347,16 @@ async def oauth_login(request: Request):
status_code=HTTP_303_SEE_OTHER status_code=HTTP_303_SEE_OTHER
) )
@router.get("/oauth-callback") @router.get("/oauth-callback/{provider_id}")
async def oauth_callback(request: Request, code: Optional[str] = None, state: Optional[str] = None, error: Optional[str] = None, db: Session = Depends(get_db)): async def oauth_callback(
"""Handle the OAuth callback""" request: Request,
provider_id: str,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Handle the OAuth callback for the specified provider"""
if error: if error:
return RedirectResponse( return RedirectResponse(
f"/auth/login?error=OAuth+login+failed:+{error}", f"/auth/login?error=OAuth+login+failed:+{error}",
@@ -359,11 +371,11 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
# Generate the redirect URI that matches the one used in the initial request # Generate the redirect URI that matches the one used in the initial request
base_url = str(request.base_url) base_url = str(request.base_url)
redirect_uri = f"{base_url}auth/oauth-callback" redirect_uri = f"{base_url}auth/oauth-callback/{provider_id}"
try: try:
# Get user info from the provider # Get normalized user info from the provider
user_info = await authentik_oauth.get_user_info(request, redirect_uri, code) user_info = await oauth_manager.get_user_info(request, provider_id, redirect_uri, code)
if not user_info: if not user_info:
return RedirectResponse( return RedirectResponse(
@@ -371,24 +383,43 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
status_code=HTTP_303_SEE_OTHER status_code=HTTP_303_SEE_OTHER
) )
# Extract user details from OAuth info # Extract user details from the normalized OAuth info
sub = user_info.get("sub", "") provider_user_id = user_info.get("id")
email = user_info.get("email", "") email = user_info.get("email")
name = user_info.get("preferred_username", "") or user_info.get("name", "") or email.split("@")[0] name = user_info.get("name") or (email.split("@")[0] if email else f"user_{provider_id}")
picture = user_info.get("picture", None) first_name = user_info.get("first_name")
last_name = user_info.get("last_name")
picture = user_info.get("picture")
# Check if the user already exists if not email:
return RedirectResponse(
"/auth/login?error=Email+address+not+provided+by+the+OAuth+provider",
status_code=HTTP_303_SEE_OTHER
)
# Check if the user already exists with this email
user = db.query(User).filter(User.email == email).first() user = db.query(User).filter(User.email == email).first()
if not user: if not user:
print(f"Creating new user with email {email} and username {name}") # Set username to be unique if it already exists
username = name
base_username = username
counter = 1
# Check if username already exists
while db.query(User).filter(User.username == username).first():
username = f"{base_username}{counter}"
counter += 1
# Create a new user # Create a new user
user = User( user = User(
username=name, username=username,
email=email, email=email,
oauth_id=sub, oauth_id=provider_user_id,
is_oauth_user=True, is_oauth_user=True,
oauth_provider="authentik", oauth_provider=provider_id,
first_name=first_name,
last_name=last_name,
picture=picture, picture=picture,
is_verified=True # OAuth users are considered verified is_verified=True # OAuth users are considered verified
) )
@@ -399,13 +430,27 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
# Update existing user's OAuth information # Update existing user's OAuth information
if not user.is_oauth_user: if not user.is_oauth_user:
user.is_oauth_user = True user.is_oauth_user = True
user.oauth_id = sub user.oauth_id = provider_user_id
user.oauth_provider = "authentik"
# Update profile picture if available # If user has a different OAuth provider, add this one as additional
if user.oauth_provider and user.oauth_provider != provider_id:
if not user.additional_oauth_providers:
user.additional_oauth_providers = {}
user.additional_oauth_providers[provider_id] = provider_user_id
else:
user.oauth_provider = provider_id
# Update profile info if not already set
if first_name and not user.first_name:
user.first_name = first_name
if last_name and not user.last_name:
user.last_name = last_name
if picture and not user.picture: if picture and not user.picture:
user.picture = picture user.picture = picture
# Update last login time
user.last_login = datetime.utcnow()
db.commit() db.commit()
# Set session data # Set session data
@@ -413,8 +458,11 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
request.session["username"] = user.username request.session["username"] = user.username
request.session["is_authenticated"] = True request.session["is_authenticated"] = True
request.session["is_admin"] = user.is_admin request.session["is_admin"] = user.is_admin
request.session["oauth_provider"] = provider_id # Store the provider for possible UI customization
return RedirectResponse("/dashboard", status_code=HTTP_303_SEE_OTHER) # Redirect to dashboard or the requested next page
next_page = request.query_params.get("next", "/dashboard")
return RedirectResponse(next_page, status_code=HTTP_303_SEE_OTHER)
except Exception as e: except Exception as e:
print(f"OAuth callback error: {str(e)}") print(f"OAuth callback error: {str(e)}")
@@ -423,6 +471,36 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
status_code=HTTP_303_SEE_OTHER status_code=HTTP_303_SEE_OTHER
) )
# Legacy route for backward compatibility
@router.get("/oauth-login")
async def legacy_oauth_login(request: Request):
"""Redirect to Authentik OAuth login for backward compatibility"""
return RedirectResponse("/auth/oauth-login/authentik", status_code=HTTP_302_FOUND)
@router.get("/oauth-callback")
async def legacy_oauth_callback(
request: Request,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Redirect to Authentik OAuth callback handler for backward compatibility"""
params = []
if code:
params.append(f"code={code}")
if state:
params.append(f"state={state}")
if error:
params.append(f"error={error}")
query_string = "&".join(params)
redirect_url = f"/auth/oauth-callback/authentik"
if query_string:
redirect_url = f"{redirect_url}?{query_string}"
return RedirectResponse(redirect_url, status_code=HTTP_302_FOUND)
@router.get("/logout") @router.get("/logout")
async def logout(request: Request): async def logout(request: Request):
"""Log out the user""" """Log out the user"""
+59
View File
@@ -18,6 +18,7 @@ from ..schemas import TeamCreate
from ..templates_config import templates from ..templates_config import templates
from ..utils.auth import get_current_user, is_team_captain from ..utils.auth import get_current_user, is_team_captain
from ..utils.mail import send_team_join_request_notification, send_join_request_response from ..utils.mail import send_team_join_request_notification, send_join_request_response
from ..auth.permissions import require_auth, require_team_captain
router = APIRouter() router = APIRouter()
@@ -605,3 +606,61 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
"is_open": is_open # Pass is_open to the template "is_open": is_open # Pass is_open to the template
} }
) )
@router.get("/{team_id}/manage", response_class=HTMLResponse)
@require_team_captain()
async def manage_team(request: Request, team_id: int, db: Session = Depends(get_db)):
"""
Team management page for captains.
This route is protected by the require_team_captain decorator which
automatically checks if the user is a captain of the team.
"""
# Get team
team = db.query(Team).filter_by(id=team_id).first()
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Get pending join requests
join_requests = db.query(TeamJoinRequest).filter(
TeamJoinRequest.team_id == team_id,
TeamJoinRequest.status == "pending"
).all()
# Get requesters' info
pending_requests = []
for req in join_requests:
requester = db.query(User).filter(User.id == req.user_id).first()
if requester:
pending_requests.append({
"request": req,
"user": requester
})
# Get team members
memberships = db.query(TeamMembership).filter_by(team_id=team_id).all()
team_members = []
for membership in memberships:
member = db.query(User).filter_by(id=membership.user_id).first()
if member:
team_members.append({
"user": member,
"is_admin": membership.is_admin,
"is_captain": membership.is_captain
})
# Get current user from session for template
current_user = None
if hasattr(request, "session") and request.session.get("user_id"):
current_user = db.query(User).filter_by(id=request.session.get("user_id")).first()
return templates.TemplateResponse(
"teams/manage.html",
{
"request": request,
"team": team,
"pending_requests": pending_requests,
"team_members": team_members,
"user": current_user # Use current user from session instead of request.user
}
)
+36 -2
View File
@@ -28,15 +28,18 @@ services:
db: db:
condition: service_healthy condition: service_healthy
environment: environment:
# Database configuration
DB_HOST: "db" DB_HOST: "db"
DB_PORT: "3306" DB_PORT: "3306"
DB_NAME: "pubquiz_db" DB_NAME: "pubquiz_db"
DB_USER: "pubquiz_user" DB_USER: "pubquiz_user"
DB_PASS: "pubquiz_pass" DB_PASS: "pubquiz_pass"
PYTHONUNBUFFERED: "1" PYTHONUNBUFFERED: "1"
# Use environment variable from .env instead of hard-coding
# Base URL
LEAGUELEDGER_BASE_URL: ${LEAGUELEDGER_BASE_URL:-http://localhost:8000} LEAGUELEDGER_BASE_URL: ${LEAGUELEDGER_BASE_URL:-http://localhost:8000}
# Email configuration for Mailhog
# Email configuration for Mailpit
MAIL_USERNAME: "" MAIL_USERNAME: ""
MAIL_PASSWORD: "" MAIL_PASSWORD: ""
MAIL_FROM: "noreply@leagueledger.net" MAIL_FROM: "noreply@leagueledger.net"
@@ -47,6 +50,37 @@ services:
MAIL_SSL_TLS: "False" MAIL_SSL_TLS: "False"
MAIL_USE_CREDENTIALS: "False" MAIL_USE_CREDENTIALS: "False"
MAIL_VALIDATE_CERTS: "False" MAIL_VALIDATE_CERTS: "False"
# OAuth configuration - read from .env file
# Authentik
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
AUTHENTIK_CONFIG_URL: ${AUTHENTIK_CONFIG_URL:-}
# Google OAuth
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
# GitHub OAuth
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
# Facebook OAuth
FACEBOOK_CLIENT_ID: ${FACEBOOK_CLIENT_ID:-}
FACEBOOK_CLIENT_SECRET: ${FACEBOOK_CLIENT_SECRET:-}
# Microsoft OAuth
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-}
MICROSOFT_TENANT: ${MICROSOFT_TENANT:-common}
# Discord OAuth
DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:-}
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:-}
# LinkedIn OAuth
LINKEDIN_CLIENT_ID: ${LINKEDIN_CLIENT_ID:-}
LINKEDIN_CLIENT_SECRET: ${LINKEDIN_CLIENT_SECRET:-}
command: uvicorn app.main:app --host 0.0.0.0 --reload command: uvicorn app.main:app --host 0.0.0.0 --reload
ports: ports:
- "8000:8000" - "8000:8000"
+142
View File
@@ -0,0 +1,142 @@
# Admin Panel
The LeagueLedger Admin Panel provides administrators with powerful tools to manage all aspects of the system. This guide explains how to access and use the admin panel effectively.
## Accessing the Admin Panel
To access the admin panel:
1. Log in to LeagueLedger using an admin account
2. Click on your profile icon in the top-right corner
3. Select "Admin Panel" from the dropdown menu
!!! note "Admin Privileges"
Only users with admin privileges can access the admin panel. If you don't see this option, contact your system administrator.
## Admin Panel Dashboard
The admin dashboard provides an overview of system activity and key metrics:
- **User Statistics**: Total users, active users, new registrations
- **Team Statistics**: Total teams, active teams, team distribution
- **Event Statistics**: Upcoming events, past events, attendance rates
- **System Health**: Database status, background tasks, recent errors
## Main Admin Sections
### User Management
In this section, you can manage all user accounts:
- **View Users**: See a list of all registered users with filtering options
- **Create Users**: Manually create new user accounts
- **Edit Users**: Modify existing user information
- **Verify/Unverify Users**: Manually verify or unverify user accounts
- **Reset Passwords**: Help users recover access to their accounts
- **Assign Roles**: Grant or revoke admin privileges
- **Disable Accounts**: Temporarily or permanently disable user accounts
### Team Management
Manage teams and their members:
- **View Teams**: Browse all teams with filtering and sorting options
- **Create Teams**: Create new teams manually
- **Edit Teams**: Update team information
- **Manage Members**: Add or remove team members
- **Transfer Ownership**: Change team ownership
- **Archive Teams**: Deactivate teams when needed
### QR Code Management
Create and manage QR codes for points and achievements:
- **Create QR Codes**: Generate new QR codes with specified point values
- **Create QR Sets**: Group QR codes into themed sets for events
- **View Usage**: Track which QR codes have been redeemed
- **Print QR Codes**: Generate printable sheets for distribution
- **Invalidate QR Codes**: Disable QR codes if needed
### Event Management
Create and manage events:
- **Create Events**: Set up new events with date, time, and location
- **Edit Events**: Modify event details
- **Assign QR Sets**: Connect QR code sets to specific events
- **Track Attendance**: Monitor event participation
- **View Results**: See points and achievements awarded at events
### System Configuration
Configure system-wide settings:
- **Email Settings**: Configure email server details and templates
- **OAuth Providers**: Set up social login integration
- **Appearance Settings**: Customize branding and UI elements
- **General Settings**: Adjust system behavior and defaults
## Administrative Tasks
### Running Reports
Generate reports to analyze system data:
1. Navigate to the "Reports" section in the admin panel
2. Select the report type (users, teams, events, etc.)
3. Set the parameters and date range
4. Click "Generate Report"
5. View on screen or export to CSV/PDF
### Managing Achievements
Create and assign achievements:
1. Go to the "Achievements" section
2. Create achievement types with names, descriptions, and icons
3. Set automatic achievement criteria or assign manually
4. Link achievements to QR codes if desired
### System Backup
Back up your system data:
1. Navigate to "System Tools"
2. Select "Backup Database"
3. Choose backup options (full or partial)
4. Initiate the backup process
5. Download the backup file or save to a configured location
## Best Practices
- **Regular Maintenance**: Schedule regular system checks and database optimization
- **User Audits**: Periodically review user accounts and permissions
- **QR Security**: Create new QR codes for each event to prevent reuse
- **Data Backup**: Back up the database before making significant changes
- **Testing**: Test new configurations in a staging environment before deploying
## Troubleshooting
### Common Issues
- **User Can't Log In**: Check account status, verification status, and credentials
- **QR Codes Not Working**: Verify QR code validity and ensure they're not already redeemed
- **Email Delivery Problems**: Check email server settings and test mail functionality
- **Performance Issues**: Monitor database size, optimize queries, check server resources
### Getting Support
If you encounter issues that you can't resolve:
1. Check the [documentation](../index.md) for relevant guidance
2. Consult the [developer documentation](../development/architecture.md) for technical details
3. Contact system support with specific error information and screenshots
## Next Steps
For more detailed information about specific administrative functions, please refer to the following guides:
- [User Management](user-management.md)
- [Team Management](team-management.md)
- [QR Code Management](qr-code-management.md)
- [Event Management](event-management.md)
+344
View File
@@ -0,0 +1,344 @@
# Docker Deployment
This guide covers deploying LeagueLedger using Docker and Docker Compose, which is the recommended approach for both development and production environments.
## Prerequisites
Before deploying LeagueLedger with Docker, ensure you have:
- **Docker**: Version 20.10.0 or higher
- **Docker Compose**: Version 2.0.0 or higher
- **Git**: For cloning the repository (optional)
- **Basic Docker knowledge**: Understanding of containers and Docker Compose
## Quick Deployment
For a quick deployment using default settings:
```bash
# Clone the repository
git clone https://github.com/yourusername/leagueledger.git
cd leagueledger
# Create and configure the environment file
cp .env.example .env
# Edit the .env file with your preferred text editor
# Start the containers
docker-compose up -d
```
## Docker Compose Configuration
LeagueLedger's Docker setup includes multiple services defined in `docker-compose.yml`:
### Services Overview
- **app**: The main LeagueLedger application
- **db**: MySQL database for persistent storage
- **phpmyadmin**: Web interface for database management
- **mailpit**: Email testing service that captures all outgoing emails
### Important Configuration Parameters
#### Application Service
```yaml
app:
build: .
container_name: pubquiz_app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
# Database configuration
DB_HOST: "db"
DB_PORT: "3306"
DB_NAME: "pubquiz_db"
DB_USER: "pubquiz_user"
DB_PASS: "pubquiz_pass"
# ... other environment variables
ports:
- "8000:8000"
volumes:
- ./:/app:delegated
```
#### Database Service
```yaml
db:
image: mysql:8.0
container_name: pubquiz_mysql
restart: always
environment:
MYSQL_DATABASE: "pubquiz_db"
MYSQL_USER: "pubquiz_user"
MYSQL_PASSWORD: "pubquiz_pass"
MYSQL_ROOT_PASSWORD: "root_pass"
ports:
- "3306:3306"
# ... other settings
```
## Environment Configuration
The `.env` file contains important configuration options:
```
# Database Configuration
DATABASE_URL=mysql+pymysql://pubquiz_user:pubquiz_pass@db:3306/pubquiz_db
# Security
SECRET_KEY=your-secure-secret-key
# Email Configuration
MAIL_USERNAME=your-email@example.com
MAIL_PASSWORD=your-email-password
MAIL_FROM=noreply@example.com
MAIL_PORT=587
MAIL_SERVER=smtp.example.com
MAIL_TLS=True
MAIL_SSL=False
MAIL_FROM_NAME=LeagueLedger
# OAuth Configuration
# ... provider-specific settings
```
## Production Deployment Considerations
For production deployments, make the following adjustments:
### 1. Secure Database Configuration
Update the MySQL environment variables in `docker-compose.yml`:
```yaml
db:
environment:
MYSQL_DATABASE: "your_production_db"
MYSQL_USER: "your_production_user"
MYSQL_PASSWORD: "your_strong_password"
MYSQL_ROOT_PASSWORD: "your_very_strong_root_password"
```
### 2. Persistent Storage
Add volumes for persistent data storage:
```yaml
db:
volumes:
- leagueledger_db_data:/var/lib/mysql
volumes:
leagueledger_db_data:
```
### 3. Email Configuration
For production, replace Mailpit with a real SMTP server in your `.env` file:
```
MAIL_USERNAME=your-production-email@yourdomain.com
MAIL_PASSWORD=your-email-password
MAIL_FROM=noreply@yourdomain.com
MAIL_PORT=587
MAIL_SERVER=smtp.yourdomain.com
MAIL_TLS=True
MAIL_SSL=False
MAIL_FROM_NAME=LeagueLedger
```
### 4. HTTPS Setup
For secure access, you should add an HTTPS proxy such as Traefik or Nginx:
```yaml
services:
app:
# ... existing configuration
labels:
- "traefik.enable=true"
- "traefik.http.routers.leagueledger.rule=Host(`leagueledger.yourdomain.com`)"
- "traefik.http.routers.leagueledger.entrypoints=websecure"
- "traefik.http.routers.leagueledger.tls.certresolver=myresolver"
traefik:
image: traefik:v2.9
ports:
- "80:80"
- "443:443"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "./traefik/config:/etc/traefik"
- "./traefik/letsencrypt:/letsencrypt"
# ... additional Traefik configuration
```
### 5. OAuth Callback URLs
Update the OAuth provider configuration in your `.env` file to use your production domain:
```
# OAuth Callback URLs
LEAGUELEDGER_BASE_URL=https://leagueledger.yourdomain.com
```
## Container Management
### Starting Services
```bash
# Start all services in the background
docker-compose up -d
# Start a specific service
docker-compose up -d app
```
### Stopping Services
```bash
# Stop all services
docker-compose down
# Stop services without removing containers
docker-compose stop
```
### Viewing Logs
```bash
# View logs for all services
docker-compose logs
# Follow logs for a specific service
docker-compose logs -f app
# See the last 100 lines of logs
docker-compose logs --tail=100 app
```
### Restarting Services
```bash
# Restart all services
docker-compose restart
# Restart a specific service
docker-compose restart app
```
## Database Management
### Accessing the Database
You can access the database using phpMyAdmin at:
```
http://localhost:8001
```
Or connect directly to MySQL:
```bash
docker-compose exec db mysql -upubquiz_user -ppubquiz_pass pubquiz_db
```
### Database Backups
Create a backup:
```bash
docker-compose exec db mysqldump -uroot -proot_pass pubquiz_db > backup_$(date +%Y-%m-%d_%H-%M-%S).sql
```
Restore a backup:
```bash
cat backup_file.sql | docker-compose exec -T db mysql -uroot -proot_pass pubquiz_db
```
## Troubleshooting
### Common Issues
#### Container Fails to Start
Check the logs:
```bash
docker-compose logs app
```
#### Database Connection Issues
Verify the database is running and healthy:
```bash
docker-compose ps db
```
Ensure environment variables are correct:
```bash
docker-compose exec app env | grep DB_
```
#### Email Not Working
Check Mailpit interface at `http://localhost:8025` to see if emails are being captured.
If using a real SMTP server, verify credentials and connectivity:
```bash
docker-compose exec app python -c "from app.utils.mail import test_mail_connection; test_mail_connection()"
```
## Updating LeagueLedger
To update to a newer version:
```bash
# Pull the latest changes
git pull
# Rebuild and restart containers
docker-compose up -d --build
```
## Scaling for Production
For high-traffic production environments, consider:
1. **Horizontal Scaling**: Run multiple instances behind a load balancer
2. **Database Scaling**: Move the database to a managed service
3. **Redis Cache**: Add a Redis container for improved performance
4. **CDN Integration**: Use a CDN for static assets
A more advanced `docker-compose.prod.yml` might include:
```yaml
version: "3.9"
services:
app:
deploy:
replicas: 3
environment:
REDIS_URL: "redis://redis:6379/0"
redis:
image: redis:7.0
volumes:
- redis_data:/data
db:
volumes:
- db_data:/var/lib/mysql
volumes:
db_data:
redis_data:
```
## Next Steps
- [Production Setup](production.md): Additional production environment considerations
- [Scaling](scaling.md): Detailed guidance on scaling LeagueLedger
- [Backup & Recovery](backup-recovery.md): Comprehensive backup strategies
+188
View File
@@ -0,0 +1,188 @@
# System Architecture
This document provides an overview of the LeagueLedger system architecture to help developers understand the system's structure and components.
## Overview
LeagueLedger is built with a modern web architecture using FastAPI as the backend framework and a combination of server-rendered templates and JavaScript for the frontend. The system follows a modular design pattern to maintain separation of concerns and enable easy extension.
## Architecture Diagram
```mermaid
graph TD
Client[Client Browser] --> FastAPI[FastAPI Application]
FastAPI --> Templates[Jinja2 Templates]
FastAPI --> Static[Static Files]
FastAPI --> Auth[Authentication]
FastAPI --> DB[Database]
Auth --> OAuth[OAuth Providers]
Auth --> Local[Local Auth]
FastAPI --> Email[Email Service]
FastAPI --> QR[QR Code Generation]
subgraph "Data Layer"
DB --> SQLAlchemy[SQLAlchemy ORM]
SQLAlchemy --> Models[Data Models]
end
subgraph "Application Layer"
FastAPI --> Routes[API Routes]
Routes --> Views[View Controllers]
Views --> Services[Services]
end
```
## Core Components
### Backend Framework
LeagueLedger uses [FastAPI](https://fastapi.tiangolo.com/), a modern, high-performance web framework for building APIs with Python 3.7+ based on standard Python type hints.
Key FastAPI components used:
- **Dependency Injection**: For database sessions, authentication, and other services
- **Pydantic Models**: For data validation and serialization
- **Middleware**: For session management, authentication, and error handling
### Database
The system uses SQLAlchemy as an ORM (Object-Relational Mapper) to interact with the database. Key database components include:
- **SQLAlchemy Models**: Defined in `app/models/`
- **Database Configuration**: Found in `app/db.py`
- **Migrations**: Handled through custom migration scripts in `app/db_migrations.py`
The data model centers around these core entities:
- **Users**: User accounts and authentication
- **Teams**: Groups of users competing together
- **TeamMemberships**: Relationship between users and teams
- **QRCodes**: Generated codes for awarding points
- **QRSets**: Collections of QR codes for specific events
- **Events**: Scheduled activities
- **TeamAchievements**: Recognitions earned by teams
### Authentication System
Authentication is handled through multiple mechanisms:
- **Session-based Authentication**: For traditional username/password login
- **OAuth Authentication**: For social login via multiple providers
- **Authentication Middleware**: Integrated with Starlette's authentication system
OAuth providers are implemented as pluggable components, allowing easy addition of new providers.
### Frontend
The frontend is primarily built with:
- **Jinja2 Templates**: For server-side rendering of HTML
- **Tailwind CSS**: For responsive styling
- **JavaScript**: For interactive elements
- **Static Assets**: CSS, JS, images stored in `app/static/`
### Template Engine
[Jinja2](https://jinja.palletsprojects.com/) is used as the template engine with:
- **Base Templates**: Providing layout scaffolding
- **Template Inheritance**: Enabling consistent UI across pages
- **Template Globals**: For user context and common functions
### QR Code System
QR codes are central to the application's functionality:
- **Generation**: Creating unique QR codes with the `qrcode` library
- **Scanning**: Web-based scanning using the device camera
- **Points Attribution**: Mapping scanned codes to point values and teams
### Internationalization
The application supports multiple languages through:
- **Babel**: For i18n infrastructure
- **Translation Files**: Stored in `app/i18n/locales/`
- **Language Selection**: User-configurable preferences
## Data Flow
### Request Lifecycle
1. **Client Request**: Browser sends HTTP request
2. **Middleware Processing**: Session, authentication, template globals
3. **Route Handling**: Matching URL to appropriate handler
4. **View Controller**: Processing business logic
5. **Database Interactions**: Through SQLAlchemy models
6. **Template Rendering**: Creating HTML with Jinja2
7. **Response**: Returning HTML or redirect to client
### Authentication Flow
1. **Login Request**: User submits credentials
2. **Verification**: Checking against stored hash
3. **Session Creation**: Creating session on successful auth
4. **OAuth Flow** (for social login):
- Redirect to provider
- Provider authentication
- Callback with authorization code
- Token exchange
- User info retrieval
- Account creation or linking
## Directory Structure
```
leagueledger/
├── app/ # Application code
│ ├── auth/ # Authentication components
│ ├── i18n/ # Internationalization
│ ├── models/ # Database models
│ ├── static/ # Static files
│ ├── templates/ # HTML templates
│ ├── utils/ # Utility functions
│ └── views/ # View controllers
├── docs/ # Documentation
├── scripts/ # Helper scripts
└── tests/ # Test suite
```
## Development Patterns
### Dependency Injection
FastAPI's dependency injection system is used extensively to:
- Provide database sessions
- Ensure authentication
- Validate permissions
- Supply configuration
Example:
```python
@router.get("/secure-endpoint")
async def secure_endpoint(db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)):
# Function implementation
```
### Service Pattern
Business logic is organized into service modules to separate concerns:
- **Data access**: Database operations
- **Business rules**: Application logic
- **Presentation**: View rendering and response formatting
### Error Handling
Centralized error handling through:
- **Exception handlers**: For API errors
- **Custom templates**: For user-friendly error pages
- **Logging**: Comprehensive error logging
## Next Steps
For more detailed information about the development aspects, refer to:
- [API Reference](api-reference.md)
- [Database Schema](database-schema.md)
- [Frontend Development](frontend-dev.md)
- [Backend Development](backend-dev.md)
- [Testing](testing.md)
+143
View File
@@ -0,0 +1,143 @@
# Installation Guide
This guide will walk you through the process of installing LeagueLedger on your system.
## Prerequisites
Before installing LeagueLedger, make sure you have the following prerequisites:
- Python 3.10 or higher
- pip (Python package manager)
- Git (optional, for cloning the repository)
- Docker and Docker Compose (optional, for containerized deployment)
## Option 1: Installation with Docker (Recommended)
The easiest way to get LeagueLedger up and running is using Docker and Docker Compose.
### Step 1: Clone the Repository
```bash
git clone https://github.com/yourusername/leagueledger.git
cd leagueledger
```
### Step 2: Create Environment File
Create a `.env` file in the project root or copy from the example:
```bash
cp .env.example .env
```
Edit the `.env` file to configure your environment variables.
### Step 3: Start with Docker Compose
```bash
docker-compose up -d
```
This will start all the required services including:
- Web application
- MySQL database
- PHPMyAdmin for database management
- Mailpit for email testing
### Step 4: Access the Application
Once the containers are running, you can access:
- LeagueLedger web interface at [http://localhost:8000](http://localhost:8000)
- PHPMyAdmin at [http://localhost:8001](http://localhost:8001)
- Mailpit (email testing) at [http://localhost:8025](http://localhost:8025)
## Option 2: Manual Installation
For development or if you prefer not to use Docker, you can install LeagueLedger manually.
### Step 1: Clone the Repository
```bash
git clone https://github.com/yourusername/leagueledger.git
cd leagueledger
```
### Step 2: Create a Virtual Environment
```bash
python -m venv venv
```
Activate the virtual environment:
=== "Windows"
```
venv\Scripts\activate
```
=== "macOS/Linux"
```
source venv/bin/activate
```
### Step 3: Install Dependencies
```bash
pip install -r requirements.txt
```
### Step 4: Configure Environment Variables
Create a `.env` file in the project root with the following content:
```
SECRET_KEY=your-secure-secret-key
DATABASE_URL=sqlite:///./leagueledger.db
# Email configuration
MAIL_USERNAME=your-email@example.com
MAIL_PASSWORD=your-email-password
MAIL_FROM=noreply@example.com
MAIL_PORT=587
MAIL_SERVER=smtp.example.com
MAIL_TLS=True
MAIL_SSL=False
MAIL_FROM_NAME=LeagueLedger
```
Customize the values as needed.
### Step 5: Initialize the Database
```bash
python -c "from app.db_init import init_db; init_db()"
```
### Step 6: Run the Application
```bash
uvicorn app.main:app --reload
```
The application should now be accessible at [http://localhost:8000](http://localhost:8000).
## Verifying the Installation
After installation, you can verify that LeagueLedger is working correctly by:
1. Opening your browser and navigating to [http://localhost:8000](http://localhost:8000)
2. Creating a new user account via the registration page
3. Logging in with your new credentials
The default admin credentials for the seeded database are:
- Username: `admin`
- Password: `password`
!!! warning "Security Note"
If using the seeded database in production, make sure to change the default admin password immediately.
## Next Steps
- [Configuration Guide](configuration.md): Configure LeagueLedger for your specific needs
- [Quick Start Guide](quick-start.md): Get started with using LeagueLedger
- [Social Login Setup](../integrations/social-login.md): Set up authentication with social media providers
+49
View File
@@ -0,0 +1,49 @@
# LeagueLedger Documentation
Welcome to the official documentation for LeagueLedger, a comprehensive team management and points tracking system designed for organizing competitions, tracking achievements, and managing team-based events.
## About LeagueLedger
LeagueLedger is a flexible platform that helps event organizers, team managers, and participants track points, achievements, and standings. Whether you're running pub quizzes, sports leagues, or any team-based competition, LeagueLedger provides the tools to streamline your operations.
## Key Features
- **Team Management**: Create, join, and manage teams with flexible access controls
- **Points Tracking**: Award points to teams and individuals through various mechanisms
- **QR Code System**: Generate and scan QR codes for easy point attribution
- **Achievements**: Recognize accomplishments with customizable achievements
- **Leaderboard**: Real-time standings for teams and individuals
- **Event Management**: Organize and track attendance for events
- **Social Login**: Multiple authentication options for streamlined user access
- **Responsive Design**: Works on desktop and mobile devices
## Documentation Structure
This documentation is organized into several sections:
- **[Getting Started](getting-started/installation.md)**: Installation, configuration, and quick start guide
- **[User Guide](user-guide/overview.md)**: Comprehensive instructions for end users
- **[Administration](administration/admin-panel.md)**: Managing users, teams, QR codes, and events
- **[Development](development/architecture.md)**: Technical details for developers
- **[Deployment](deployment/docker.md)**: Guides for deploying to production environments
- **[Integrations](integrations/social-login.md)**: Working with external services
## Quick Links
- [Installation Guide](getting-started/installation.md)
- [User Accounts](user-guide/user-accounts.md)
- [Team Management](user-guide/teams.md)
- [QR Code System](user-guide/qr-codes.md)
- [Admin Panel](administration/admin-panel.md)
## Support
If you encounter any issues or have questions not covered in this documentation, please:
1. Check the [FAQ section](faq.md)
2. Search for similar issues in our [GitHub repository](https://github.com/yourusername/leagueledger/issues)
3. Open a new issue if your problem hasn't been addressed
## Contributing
We welcome contributions to both the LeagueLedger project and its documentation. See our [Contributing Guide](contributing.md) for more information.
+6
View File
@@ -0,0 +1,6 @@
mkdocs>=1.5.0
mkdocs-material>=9.2.0
mkdocstrings>=0.22.0
mkdocstrings-python>=1.5.0
pymdown-extensions>=10.1
mike>=1.1.2
+233
View File
@@ -0,0 +1,233 @@
# Setting Up Social Login in LeagueLedger
LeagueLedger supports multiple social login (OAuth) providers to give your users various options for authentication. This document explains how to set up each supported provider.
## Table of Contents
1. [General Setup](#general-setup)
2. [Callback URLs](#callback-urls)
3. [Provider-Specific Instructions](#provider-specific-instructions)
- [Google](#google)
- [GitHub](#github)
- [Facebook](#facebook)
- [Microsoft](#microsoft)
- [Discord](#discord)
- [LinkedIn](#linkedin)
- [Authentik](#authentik)
4. [Troubleshooting](#troubleshooting)
## General Setup
To enable social login in LeagueLedger, you need to:
1. Register your application with the desired OAuth provider(s)
2. Obtain client ID and client secret credentials
3. Add these credentials to your environment variables or `.env` file
4. Restart the application
Only providers with valid credentials will appear on the login page.
## Callback URLs
Each OAuth provider requires you to configure a **Redirect URI** (also known as a callback URL). This is where the provider redirects users after they authenticate.
For LeagueLedger, use the following pattern:
```
https://your-domain.com/auth/oauth-callback/{provider_id}
```
Replace:
- `your-domain.com` with your actual domain
- `{provider_id}` with one of: `google`, `github`, `facebook`, `microsoft`, `discord`, `linkedin`, or `authentik`
For local development, use:
```
http://localhost:8000/auth/oauth-callback/{provider_id}
```
**Important:** Most OAuth providers require exact URL matches, including protocol (http/https), domain, path, and any query parameters. Make sure to register the exact URL as shown above.
## Provider-Specific Instructions
### Google
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select an existing one
3. Navigate to "APIs & Services" > "Credentials"
4. Click "Create Credentials" > "OAuth client ID"
5. Select "Web application" as the application type
6. Add the following authorized redirect URI:
```
http://localhost:8000/auth/oauth-callback/google
```
(Plus your production URL if applicable)
7. Click "Create"
8. Note the Client ID and Client Secret
9. Add to your `.env` file:
```
GOOGLE_CLIENT_ID=your-client-id
GOOGLE_CLIENT_SECRET=your-client-secret
```
### GitHub
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
2. Click "New OAuth App"
3. Fill in your application details:
- Application name: "LeagueLedger"
- Homepage URL: Your app's URL or `http://localhost:8000`
- Authorization callback URL:
```
http://localhost:8000/auth/oauth-callback/github
```
4. Click "Register application"
5. Generate a new client secret
6. Add to your `.env` file:
```
GITHUB_CLIENT_ID=your-client-id
GITHUB_CLIENT_SECRET=your-client-secret
```
### Facebook
1. Go to [Facebook Developers](https://developers.facebook.com/)
2. Create a new app (choose "Consumer" or "Business" type)
3. Navigate to "Add a Product" > "Facebook Login" > "Web"
4. In Settings > Basic, note your App ID and App Secret
5. In Facebook Login > Settings, add the following OAuth Redirect URI:
```
http://localhost:8000/auth/oauth-callback/facebook
```
6. Add to your `.env` file:
```
FACEBOOK_CLIENT_ID=your-app-id
FACEBOOK_CLIENT_SECRET=your-app-secret
```
### Microsoft
1. Go to [Azure Portal](https://portal.azure.com/)
2. Navigate to "App registrations"
3. Click "New registration"
4. Enter a name for your application
5. For "Supported account types," choose an option based on your needs
(typically "Accounts in any organizational directory and personal Microsoft accounts")
6. Add the following Redirect URI (type: Web):
```
http://localhost:8000/auth/oauth-callback/microsoft
```
7. Click "Register"
8. Note the Application (client) ID
9. Create a client secret: Navigate to "Certificates & secrets" > "New client secret"
10. Add to your `.env` file:
```
MICROSOFT_CLIENT_ID=your-client-id
MICROSOFT_CLIENT_SECRET=your-client-secret
MICROSOFT_TENANT=common
```
Note: Use `common` for multi-tenant apps, or your specific tenant ID
### Discord
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications)
2. Click "New Application"
3. Enter a name and click "Create"
4. Go to the "OAuth2" section in the left sidebar
5. Note the Client ID and generate a Client Secret
6. Add the following redirect URL:
```
http://localhost:8000/auth/oauth-callback/discord
```
7. In the "OAuth2 URL Generator" section, select the "identify" and "email" scopes
8. Add to your `.env` file:
```
DISCORD_CLIENT_ID=your-client-id
DISCORD_CLIENT_SECRET=your-client-secret
```
### LinkedIn
1. Go to the [LinkedIn Developer Portal](https://www.linkedin.com/developers/)
2. Click "Create app"
3. Fill in the required app details:
- App name: "LeagueLedger"
- LinkedIn Page: Your company's LinkedIn page (or your personal page if needed)
- App logo: Upload your app logo
- Legal agreement: Accept the terms
4. Click "Create app"
5. Add the "Sign In with LinkedIn" product to your app
6. Configure OAuth settings:
- Authorized redirect URLs:
```
http://localhost:8000/auth/oauth-callback/linkedin
```
(Plus your production URL if applicable)
7. Under "OAuth 2.0 settings", note the Client ID and generate a Client Secret
8. Request the appropriate scopes:
- r_liteprofile (for basic profile information)
- r_emailaddress (for user email address)
9. Add to your `.env` file:
```
LINKEDIN_CLIENT_ID=your-client-id
LINKEDIN_CLIENT_SECRET=your-client-secret
```
### Authentik
1. Access your Authentik admin interface
2. Go to "Applications" > "Providers" > "Create"
3. Select "OAuth2/OIDC Provider"
4. Configure the provider:
- Name: LeagueLedger
- Client Type: Confidential
- Redirect URIs:
```
http://localhost:8000/auth/oauth-callback/authentik
```
- Signing Key: Select an appropriate key or create one
5. Save the provider
6. Create an application:
- Go to "Applications" > "Applications" > "Create"
- Name: LeagueLedger
- Slug: leagueledger
- Provider: Select the provider you just created
7. Save the application
8. Note the Client ID and Client Secret
9. Add to your `.env` file:
```
AUTHENTIK_CLIENT_ID=your-client-id
AUTHENTIK_CLIENT_SECRET=your-client-secret
AUTHENTIK_CONFIG_URL=https://your-authentik-domain/application/o/leagueledger/.well-known/openid-configuration
```
## Troubleshooting
### Common Issues:
1. **Provider not showing on login page**
- Check that client ID and secret are correctly set in your environment/`.env` file
- Verify that values are not empty strings
- Check application logs for initialization errors
2. **Authentication Error after provider login**
- Verify that the redirect URI is exactly as registered with the provider
- Check for protocol mismatch (http vs https)
- Ensure all required scopes have been granted
3. **"Can't retrieve user email" errors**
- Ensure you've requested the email scope from the provider
- Some providers (like GitHub) require special permissions for email access
### Checking Provider Status:
You can check which providers are correctly configured by examining the login page:
- Only providers with valid credentials will appear as login options
- Look at application logs during startup for provider initialization messages
### Provider-Specific Tips:
- **Google**: Ensure the Google+ API is enabled in your Google Cloud project
- **GitHub**: For private email addresses, request the `user:email` scope
- **Discord**: Discord applications might need to be verified if you have a large user base
- **Microsoft**: Ensure the Microsoft Graph API permissions include User.Read
For more help, check the [official documentation](https://example.com/leagueledger/docs) or open an issue on the project repository.
+125
View File
@@ -0,0 +1,125 @@
# LeagueLedger User Guide
Welcome to the LeagueLedger User Guide. This section provides detailed instructions on how to use LeagueLedger as an end user.
## Getting Started as a User
### Creating an Account
To use LeagueLedger, you'll first need to create an account:
1. Navigate to the [LeagueLedger homepage](http://localhost:8000)
2. Click on the "Sign Up" or "Register" button
3. Fill in the required information:
- Username
- Email address
- Password
4. Complete the verification process through the email sent to your address
5. Log in with your new credentials
### Logging In
You can log in using:
- Your username and password
- Social login (if configured by your administrator) via Google, GitHub, Microsoft, and other supported providers
## Core Features
### User Dashboard
After logging in, you'll see your dashboard with:
- Your personal points totals
- Teams you're a member of
- Recent activities and achievements
- Upcoming events
- Quick access to common actions
### Teams
Teams are the core organizational unit in LeagueLedger:
- **Joining Teams**: Find teams through search or receive invitations
- **Creating Teams**: Start your own team and invite others
- **Team Management**: View team statistics, achievements, and members
### QR Codes and Points
Points in LeagueLedger are typically awarded through QR codes:
- **Scanning QR Codes**: Use the scan feature to capture QR codes at events
- **Points History**: Track all your earned points and achievements
- **Team Points**: View how your contributions affect team standings
### Events
LeagueLedger tracks various events:
- **Upcoming Events**: See what events are scheduled
- **Event Registration**: Sign up for events individually or as a team
- **Event Attendance**: Check in to events using QR codes
### Leaderboards
Track standings and achievements:
- **Individual Leaderboards**: See how you rank among all participants
- **Team Leaderboards**: View team rankings
- **Event-specific Leaderboards**: Standings for particular events
## User Settings
### Profile Management
Customize your experience through the profile settings:
1. Navigate to "My Account" or "Settings"
2. Update your:
- Profile picture
- Personal information
- Email preferences
- Notification settings
### Account Security
Manage your account security:
- Change your password
- Enable/disable social login connections
- View active sessions
## Navigation Guide
### Main Menu
The main menu provides access to all major features:
- **Dashboard**: Your personal overview
- **Teams**: Access to teams you belong to
- **Events**: Upcoming and past events
- **QR Scanner**: Tool to scan QR codes
- **Leaderboards**: Overall standings
- **Profile**: Your personal settings
### Mobile Navigation
On mobile devices, the menu is accessible through the hamburger icon (≡) in the top corner.
## Getting Help
If you encounter any issues while using LeagueLedger:
- Check the FAQ section
- Contact your organization's administrator
- Submit a support request through the "Help" section
## Next Steps
For more detailed information about specific features, please refer to the following guides:
- [User Accounts](user-accounts.md): Detailed account management information
- [Teams](teams.md): Complete team management guide
- [QR Codes](qr-codes.md): Everything about the QR code system
- [Points & Achievements](points-and-achievements.md): How points and achievements work
- [Leaderboard](leaderboard.md): Understanding the leaderboard system
- [Events](events.md): Comprehensive events guide
+241
View File
@@ -0,0 +1,241 @@
# QR Codes System
The QR code system is a core feature of LeagueLedger, enabling easy point attribution and event participation tracking. This guide explains how QR codes work in the system and how to use them effectively.
## Overview
LeagueLedger's QR code system allows organizers to:
- Create point-valued QR codes that users can scan
- Group codes into sets for specific events or purposes
- Track redemption and usage statistics
- Print codes for physical distribution
Users can scan these codes to:
- Earn points for themselves or their team
- Check in to events
- Claim achievements
- Verify attendance
## QR Code Types
LeagueLedger supports several types of QR codes:
### Point Value Codes
These codes represent specific point values that are awarded when scanned:
- **Standard Points**: Fixed point values (e.g., 5, 10, 25 points)
- **Variable Points**: Point values that may fluctuate based on factors like time, location, or number of scans
- **Team-Specific Points**: Codes that only award points to specific teams
### Functional Codes
These codes trigger specific actions in the system:
- **Check-In Codes**: For event attendance verification
- **Achievement Codes**: Unlock specific achievements when scanned
- **Registration Codes**: Link to team registration or event signup
- **Information Codes**: Open detailed information about an event or challenge
## Scanning QR Codes
### Mobile Scanning
To scan a QR code using a mobile device:
1. Log in to LeagueLedger on your mobile browser
2. Navigate to the "Scan" option in the menu
3. Allow camera permissions if prompted
4. Point your camera at the QR code
5. The system will automatically detect and process the code
6. A confirmation screen will display the points awarded or action taken
### Desktop Scanning
For desktop users with webcams:
1. Log in to LeagueLedger
2. Click on the "Scan QR Code" option in the navigation menu
3. Allow camera permissions when prompted
4. Position the QR code in front of your webcam
5. The system will process the code once detected
### Upload Scanning
If you have a QR code image file:
1. Go to the "Scan QR" page
2. Select the "Upload QR Code Image" option
3. Choose the image file from your device
4. Submit the image for processing
## Creating QR Codes (Administrators)
Administrators can create QR codes through the admin panel:
### Creating Individual QR Codes
1. Navigate to the Admin Panel > QR Codes
2. Click on "Create New QR Code"
3. Fill in the required information:
- Point value
- Description
- Redemption limit (how many times it can be scanned)
- Expiration date (if applicable)
- Team restrictions (if applicable)
4. Click "Generate Code"
5. The new QR code will be displayed and added to the database
### Creating QR Code Sets
For organizing multiple codes together:
1. Go to Admin Panel > QR Codes > QR Sets
2. Select "Create New Set"
3. Provide a name and description for the set
4. Choose the number of codes to generate in this set
5. Configure the point values (fixed, random, or custom distribution)
6. Set any common properties (expiration, redemption limits)
7. Generate the set
### Printing QR Codes
To print physical copies of QR codes:
1. Go to Admin Panel > QR Codes or QR Sets
2. Select the code(s) you wish to print
3. Click "Print QR Codes"
4. Choose the print format:
- Standard layout
- Compact grid
- Labels
- Individual cards
5. Configure printing options (size, labels, etc.)
6. Click "Generate Printable PDF"
7. Print the generated document
## Managing QR Codes
### Monitoring Usage
Track QR code usage through the Admin Panel:
1. Go to Admin Panel > QR Codes
2. View the list of codes with usage statistics
3. Click on a specific code for detailed redemption history
4. See who scanned the code, when, and how many points were awarded
### Deactivating Codes
To disable a QR code:
1. Navigate to Admin Panel > QR Codes
2. Find the code you wish to deactivate
3. Click "Edit" or select the code
4. Toggle the "Active" status to inactive
5. Save changes
The code will remain in the system for record-keeping but can no longer be redeemed.
### Modifying Codes
To change a QR code's properties:
1. Go to Admin Panel > QR Codes
2. Select the code to modify
3. Click "Edit"
4. Update the desired properties
5. Save changes
!!! warning "Active Codes"
Modifying the point value or redemption rules of already-distributed codes may cause confusion for users. Consider creating new codes instead of changing existing ones.
## Best Practices
### Security
- **Regenerate Codes Regularly**: Create new QR codes for each event to prevent reuse
- **Limit Redemptions**: Set appropriate scan limits to prevent abuse
- **Verify Location**: For important events, consider enabling location verification
- **Monitor Unusual Activity**: Check for patterns that might indicate QR code sharing
### Organization
- **Meaningful Names**: Use descriptive names for QR sets and codes
- **Color Coding**: Consider printing different point values on different colored paper
- **Tracking Identifiers**: Include visible IDs on printed codes for easy reference
- **Backup Copies**: Maintain digital backups of all generated codes
### Distribution
- **Strategic Placement**: Place higher-value codes in less obvious locations
- **Staffed Stations**: For high-value codes, consider having staff present
- **Time-Limited Availability**: Make codes available only during specific periods
- **Progressive Difficulty**: Structure code placement so finding codes gets progressively harder
## Troubleshooting
### Common Issues
#### QR Code Not Scanning
If a code isn't being recognized:
- Ensure adequate lighting
- Hold the device steady and at an appropriate distance
- Make sure the code isn't damaged or obscured
- Try using the image upload option instead
#### Points Not Awarded
If scanning succeeds but points aren't awarded:
- Check if the user is logged in
- Verify if the code has reached its redemption limit
- Check if the code has expired
- Confirm the user hasn't already scanned this code
#### Printing Problems
For issues with printed QR codes:
- Ensure printer resolution is adequate (300 DPI minimum recommended)
- Avoid scaling codes to very small sizes
- Print test codes and verify they scan correctly before mass production
- Use high-contrast printing (black on white background)
## Use Cases and Examples
### Hunt/Challenge Events
Create a scavenger hunt by placing QR codes throughout a venue:
- Place codes with varying point values in different locations
- Create clues that lead participants to code locations
- Track progress and award bonus points for completing the full hunt
### Attendance Tracking
Use QR codes for verifying attendance:
- Generate unique check-in codes for each event
- Place codes at event entrances
- Have participants scan on arrival
- Generate attendance reports from the admin panel
### Reward Programs
Implement a progressive reward system:
- Issue QR codes for completing certain tasks
- Create achievement sets that unlock when specific codes are collected
- Offer special rewards for collecting complete sets
## Next Steps
- [Team Management](teams.md): Learn how teams accumulate and manage points
- [Events](events.md): How to integrate QR codes with events
- [Points & Achievements](points-and-achievements.md): More about the points system
- [QR Code Management](../administration/qr-code-management.md): Advanced administration of QR codes
+300
View File
@@ -0,0 +1,300 @@
# Teams
Teams are a core feature of LeagueLedger, allowing users to form groups that compete and collaborate. This guide explains how to create, join, and manage teams within the system.
## Teams Overview
In LeagueLedger, teams provide a way for users to:
- Collaborate toward common goals
- Compete against other teams
- Share resources and achievements
- Track collective progress
Each team has:
- A unique name and profile
- A team owner (creator by default)
- Team members with different roles
- A team points total (sum of members' contributions)
- Team-specific achievements and stats
## Creating a Team
### Basic Team Creation
To create a new team:
1. Log in to your LeagueLedger account
2. Navigate to the "Teams" section from the main menu
3. Click the "Create New Team" button
4. Fill in the required information:
- Team name (unique within the system)
- Short description
- Team logo (optional)
5. Select team visibility:
- Public: Visible to all users
- Private: Visible only to members and invitees
6. Choose join settings:
- Open: Anyone can join
- Request: Users can request to join
- Invite-only: Only invited users can join
7. Click "Create Team"
You'll automatically become the team owner with full administrative privileges.
### Team Settings
After creating a team, you can configure additional settings:
1. Go to your team page
2. Click on "Team Settings" (visible to team owners and admins)
3. Customize options such as:
- Banner image
- Team biography
- Contact information
- Social media links
- Team rules or guidelines
## Joining Teams
### Finding Teams to Join
To discover teams you might want to join:
1. Navigate to the "Teams" section
2. Click on "Browse Teams" or "Join Team"
3. Browse available teams with options to:
- Search by name or keywords
- Filter by various criteria
- Sort by size, activity, or points
4. Click on any team to view its details
### Joining an Open Team
For teams with "Open" join settings:
1. View the team details page
2. Click the "Join Team" button
3. You'll immediately be added as a member
### Requesting to Join
For teams with "Request" join settings:
1. View the team details page
2. Click "Request to Join"
3. Optional: Add a short message to the team owner
4. Submit your request
5. Wait for approval from a team admin or owner
6. You'll receive a notification when your request is approved or denied
### Joining via Invitation
If you receive a team invitation:
1. Check your notifications or email for the invitation
2. Click the invitation link
3. Review the team details
4. Click "Accept" to join or "Decline" to refuse
## Team Roles and Management
### Team Roles
LeagueLedger teams have a hierarchy of roles:
- **Owner**: The team creator with full control
- **Admin**: Can manage members and some team settings
- **Member**: Regular team participant
- **Guest**: Limited temporary access (optional feature)
### Team Member Management
As a team owner or admin, you can manage team members:
1. Go to your team page
2. Click on "Manage Members"
3. From this panel, you can:
- Invite new members
- Remove existing members
- Change member roles
- Review join requests
- Send team announcements
### Transferring Ownership
To transfer team ownership:
1. Go to "Team Settings" > "Advanced"
2. Select "Transfer Ownership"
3. Choose a team member to become the new owner
4. Confirm the transfer
!!! warning "Irreversible Action"
Transferring ownership cannot be undone. The new owner will have complete control over the team.
## Team Activities
### Team Points
Teams earn points when members:
- Scan QR codes
- Complete challenges
- Participate in events
- Earn achievements
- Contribute through other scoring actions
The team leaderboard reflects the cumulative points of all team members.
### Team Achievements
Teams can unlock special achievements based on:
- Total team points milestones
- Full team participation in events
- Completing special team challenges
- Consistent activity over time
Team achievements are displayed on the team profile and contribute to the team's prestige.
### Team Events
Teams can participate in events together:
1. Find an event in the "Events" section
2. Register as a team (by team owner/admin)
3. Coordinate team member participation
4. Earn team points through event activities
## Team Communication
### Team Chat
Teams have access to a built-in chat system:
1. Go to your team page
2. Click on the "Team Chat" tab
3. Send messages visible to all team members
4. Share updates, strategies, or coordinate activities
### Announcements
Team owners and admins can make official announcements:
1. Go to "Manage Members"
2. Select "Create Announcement"
3. Write your message
4. Choose notification options
5. Publish to all members
## Advanced Team Features
### Team Statistics
View detailed team performance:
1. Go to your team page
2. Select the "Statistics" tab
3. Explore metrics such as:
- Points over time
- Member contributions
- Achievement progress
- Event participation
- Comparison with other teams
### Team Challenges
Some events feature special team challenges:
- Collaborative tasks requiring multiple team members
- Inter-team competitions
- Timed challenges with team scoring
- Special team-only QR codes
### Private Team QR Codes
Team owners can create team-specific QR codes:
1. Go to "Team Settings" > "QR Codes"
2. Select "Create Team QR"
3. Configure the code settings
4. Generate and share with team members only
These codes may offer bonus points or special achievements when scanned by team members.
## Leaving or Dissolving a Team
### Leaving a Team
To leave a team you're a member of:
1. Go to the team page
2. Click on "Team Settings" or "Manage Membership"
3. Select "Leave Team"
4. Confirm your decision
!!! note "Team Owner"
If you're the team owner, you must first transfer ownership before leaving.
### Dissolving a Team
To completely dissolve a team (owner only):
1. Go to "Team Settings" > "Advanced"
2. Select "Dissolve Team"
3. Read the warning about this irreversible action
4. Enter your password to confirm
5. The team will be permanently removed
## Best Practices
### For Team Owners
- Establish clear team goals and guidelines
- Regularly communicate with team members
- Recognize individual contributions
- Delegate responsibilities to trusted admins
- Keep team information and graphics up-to-date
### For Team Members
- Regularly check team announcements
- Coordinate with teammates for events
- Share strategies for finding and scanning QR codes
- Help recruit quality new members
- Represent your team positively in competitions
## Troubleshooting
### Common Issues
#### Can't Find a Team
If you can't locate a specific team:
- Check if you spelled the team name correctly
- The team might be set to private visibility
- The team may have been dissolved
#### Can't Join a Team
If you're unable to join:
- The team might be invite-only
- Your request might be pending approval
- You may have reached the maximum number of teams you can join
- The team might have reached its member capacity
#### Points Not Showing for Team
If points aren't appearing:
- There may be a delay in point calculation
- Verify that your individual points are displaying correctly
- Check that you're properly affiliated with the team
## Next Steps
- [QR Codes](qr-codes.md): Learn how to earn points through QR codes
- [Points & Achievements](points-and-achievements.md): Understand the points system
- [Events](events.md): Discover how to participate in events as a team
- [Team Management](../administration/team-management.md): For administrators managing multiple teams
+123
View File
@@ -0,0 +1,123 @@
site_name: LeagueLedger Documentation
site_description: Documentation for LeagueLedger - Team Management and Points Tracking System
site_author: LeagueLedger Team
site_url: https://leagueledger.readthedocs.io
# Repository
repo_name: LeagueLedger
repo_url: https://github.com/yourusername/leagueledger
edit_uri: edit/main/docs/
# Copyright
copyright: Copyright &copy; 2025 LeagueLedger Team
# Configuration
theme:
name: material
language: en
features:
- navigation.tabs
- navigation.sections
- navigation.top
- search.suggest
- search.highlight
- content.tabs.link
- content.code.copy
palette:
- scheme: default
primary: green
accent: green
toggle:
icon: material/brightness-7
name: Switch to dark mode
- scheme: slate
primary: green
accent: green
toggle:
icon: material/brightness-4
name: Switch to light mode
font:
text: Roboto
code: Roboto Mono
favicon: assets/favicon.ico
icon:
logo: material/chart-areaspline
# Extensions
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.tabbed:
alternate_style: true
- pymdownx.tasklist:
custom_checkbox: true
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
- footnotes
- toc:
permalink: true
# Plugins
plugins:
- search
- mkdocstrings:
handlers:
python:
paths: [app]
options:
show_source: true
# Extra
extra:
version:
provider: mike
default: latest
social:
- icon: fontawesome/brands/twitter
link: https://twitter.com/yourusername
- icon: fontawesome/brands/github
link: https://github.com/yourusername/leagueledger
# Navigation
nav:
- Home: index.md
- Getting Started:
- Installation: getting-started/installation.md
- Configuration: getting-started/configuration.md
- Quick Start: getting-started/quick-start.md
- User Guide:
- Overview: user-guide/overview.md
- User Accounts: user-guide/user-accounts.md
- Teams: user-guide/teams.md
- QR Codes: user-guide/qr-codes.md
- Points & Achievements: user-guide/points-and-achievements.md
- Leaderboard: user-guide/leaderboard.md
- Events: user-guide/events.md
- Administration:
- Admin Panel: administration/admin-panel.md
- User Management: administration/user-management.md
- Team Management: administration/team-management.md
- QR Code Management: administration/qr-code-management.md
- Event Management: administration/event-management.md
- Development:
- Architecture: development/architecture.md
- API Reference: development/api-reference.md
- Database Schema: development/database-schema.md
- Frontend Development: development/frontend-dev.md
- Backend Development: development/backend-dev.md
- Testing: development/testing.md
- Deployment:
- Docker Deployment: deployment/docker.md
- Production Setup: deployment/production.md
- Scaling: deployment/scaling.md
- Backup & Recovery: deployment/backup-recovery.md
- Integrations:
- Social Login: integrations/social-login.md
- Email Service: integrations/email-service.md
- FAQ: faq.md
- Changelog: changelog.md
- Contributing: contributing.md
+3
View File
@@ -39,3 +39,6 @@ Pillow>=9.0.0
# PDF generation for QR code sheets # PDF generation for QR code sheets
reportlab>=3.6.12 reportlab>=3.6.12
babel>=2.12.1 babel>=2.12.1
# JSON handling for additional OAuth providers storage
simplejson>=3.19.2