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:
@@ -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
|
||||
@@ -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
@@ -1,22 +1,119 @@
|
||||
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.oauth2 import GetAccessTokenError
|
||||
from fastapi import HTTPException, Request
|
||||
from starlette.responses import RedirectResponse
|
||||
from typing import Optional, Dict, Any
|
||||
from typing import Optional, Dict, Any, List, Type
|
||||
import json
|
||||
import httpx
|
||||
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):
|
||||
self.client_id = os.getenv("AUTHENTIK_CLIENT_ID", "dRXLBdTdG6JSHqkcM0ZQBPwBVMBrG6SF32LZ1XAT")
|
||||
self.client_secret = os.getenv("AUTHENTIK_CLIENT_SECRET",
|
||||
"hn1aKecLeYj1tVc7QtsavrWjSOF4t7Ty1akVTmUqvIFJF1y0H3Myv7InUxAX6E2GLpMxxhhZZ2aUSJ9VEQz7zGcMbgUeMStxx2U7bEQxmuOGjZf0XJbOBGjdwGZYJlz7")
|
||||
self.config_url = os.getenv("AUTHENTIK_CONFIG_URL",
|
||||
"https://authentik.hosterra.net/application/o/leagueledger/.well-known/openid-configuration")
|
||||
self.client = None
|
||||
self.initialize_client()
|
||||
|
||||
@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):
|
||||
try:
|
||||
@@ -94,5 +191,701 @@ class AuthentikOAuth:
|
||||
print(f"Error getting user info: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||
|
||||
# Instantiate the OAuth client for the application to use
|
||||
authentik_oauth = AuthentikOAuth()
|
||||
class GoogleOAuth(OAuthProvider):
|
||||
"""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")
|
||||
|
||||
@@ -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
@@ -10,7 +10,8 @@ from sqlalchemy.orm import Session
|
||||
from passlib.context import CryptContext
|
||||
|
||||
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")
|
||||
|
||||
@@ -25,6 +26,14 @@ def table_has_column(engine, table_name, column_name):
|
||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||
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():
|
||||
"""Seed the database with test data."""
|
||||
db = SessionLocal()
|
||||
@@ -288,4 +297,4 @@ def seed_db():
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_db()
|
||||
init_db()
|
||||
|
||||
+86
-1
@@ -1,4 +1,4 @@
|
||||
from sqlalchemy import text, inspect
|
||||
from sqlalchemy import text, inspect, Column, String, JSON, MetaData, Table
|
||||
from .db import engine
|
||||
|
||||
def table_exists(conn, table_name):
|
||||
@@ -90,3 +90,88 @@ def apply_migrations():
|
||||
|
||||
except Exception as 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
@@ -7,16 +7,17 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from pathlib import Path
|
||||
import os
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
from dotenv import load_dotenv
|
||||
import logging
|
||||
import contextlib
|
||||
|
||||
from .db import init_db, engine, get_db
|
||||
from .db import engine, get_db
|
||||
from . import models
|
||||
from .templates_config import templates
|
||||
from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience
|
||||
from .db_init import seed_db
|
||||
from .db_migrations import apply_migrations
|
||||
from .db_init import init_db
|
||||
from .auth.middleware import SessionAuthBackend, on_auth_error
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
@@ -37,8 +38,18 @@ app.add_middleware(
|
||||
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")
|
||||
|
||||
# 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)
|
||||
|
||||
# Initialize database on startup
|
||||
@@ -49,19 +60,9 @@ async def startup_db_client():
|
||||
# Import models to ensure they're registered with Base before initialization
|
||||
from . import models
|
||||
|
||||
# Create all tables first
|
||||
# Initialize database (applies migrations and seeds data)
|
||||
init_db()
|
||||
logger.info("Base tables created 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")
|
||||
logger.info("Database initialized and migrated successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database initialization error: {str(e)}")
|
||||
|
||||
+27
-3
@@ -1,14 +1,15 @@
|
||||
#!/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.sql import func
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from datetime import datetime
|
||||
from starlette.authentication import BaseUser
|
||||
|
||||
# This Base should be the single source of truth
|
||||
Base = declarative_base()
|
||||
|
||||
class User(Base):
|
||||
class User(Base, BaseUser):
|
||||
__tablename__ = "users"
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||
@@ -29,7 +30,14 @@ class User(Base):
|
||||
# OAuth fields
|
||||
is_oauth_user = Column(Boolean, default=False)
|
||||
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
|
||||
|
||||
# Relationships
|
||||
@@ -38,6 +46,22 @@ class User(Base):
|
||||
events_attended = relationship("EventAttendee", back_populates="user")
|
||||
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):
|
||||
return f"<User {self.username}>"
|
||||
|
||||
|
||||
@@ -87,27 +87,34 @@
|
||||
</div>
|
||||
|
||||
<!-- OAuth login options -->
|
||||
{% if show_oauth %}
|
||||
<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 %}
|
||||
{% 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>
|
||||
|
||||
<!-- For 2 or fewer providers, show them side by side -->
|
||||
{% if oauth_providers|length <= 2 %}
|
||||
<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>
|
||||
<i class="fab fa-google mr-2"></i> Google
|
||||
</button>
|
||||
<button class="bg-gray-800 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full disabled:opacity-50" disabled>
|
||||
<i class="fab fa-github mr-2"></i> GitHub
|
||||
</button>
|
||||
{% 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>
|
||||
<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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
+31
-2
@@ -1,15 +1,19 @@
|
||||
"""
|
||||
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 fastapi import Request, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from ..db import get_db
|
||||
from ..models import User, TeamMembership
|
||||
from starlette.authentication import UnauthenticatedUser
|
||||
|
||||
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:
|
||||
request: The FastAPI request object
|
||||
@@ -18,6 +22,11 @@ async def get_current_user(request: Request, db: Session = Depends(get_db)) -> O
|
||||
Returns:
|
||||
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")
|
||||
|
||||
if not user_id:
|
||||
@@ -61,7 +70,7 @@ async def is_team_captain(
|
||||
is_captain = db.query(TeamMembership).filter(
|
||||
TeamMembership.team_id == team_id,
|
||||
TeamMembership.user_id == user.id,
|
||||
TeamMembership.role == "captain"
|
||||
TeamMembership.is_captain == True # Changed from role="captain" to is_captain=True
|
||||
).first()
|
||||
|
||||
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:
|
||||
"""
|
||||
Check if the current user is an admin.
|
||||
Now checks Starlette auth scopes first.
|
||||
|
||||
Args:
|
||||
request: FastAPI request object
|
||||
@@ -77,6 +87,11 @@ async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool:
|
||||
Returns:
|
||||
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)
|
||||
|
||||
if not user:
|
||||
@@ -97,6 +112,11 @@ async def requires_login(request: Request, db: Session = Depends(get_db)) -> Opt
|
||||
"""
|
||||
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)
|
||||
|
||||
if not user:
|
||||
@@ -121,6 +141,12 @@ async def requires_admin(request: Request, db: Session = Depends(get_db)) -> Use
|
||||
"""
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
# Note: For new code, consider using the decorators in app.auth.permissions instead
|
||||
# of these dependency functions directly
|
||||
|
||||
+13
-44
@@ -16,6 +16,7 @@ from ..models import (
|
||||
OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints
|
||||
)
|
||||
from ..templates_config import templates
|
||||
from ..auth.permissions import require_admin
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -74,25 +75,18 @@ def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
|
||||
return relationships
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
@require_admin(redirect_url="/auth/login?next=/admin/")
|
||||
async def admin_home(request: Request, db: Session = Depends(get_db)):
|
||||
"""Admin dashboard home."""
|
||||
# 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")
|
||||
|
||||
# Since we're using Starlette's authentication, the user is now available in request.user
|
||||
model_list = [(key, name) for key, (_, name) in MODELS.items()]
|
||||
return templates.TemplateResponse(
|
||||
"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)
|
||||
@require_admin(redirect_url="/auth/login")
|
||||
async def list_records(
|
||||
request: Request,
|
||||
model_name: str,
|
||||
@@ -101,16 +95,6 @@ async def list_records(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""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:
|
||||
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
||||
|
||||
@@ -150,27 +134,18 @@ async def list_records(
|
||||
"per_page": per_page,
|
||||
"total_pages": total_pages,
|
||||
"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)
|
||||
@require_admin(redirect_url="/auth/login")
|
||||
async def create_record_form(
|
||||
request: Request,
|
||||
model_name: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""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:
|
||||
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
|
||||
"foreign_key_options": foreign_key_options,
|
||||
"is_new": True,
|
||||
"user": user # Add user to the context
|
||||
"user": request.user # Use request.user from Starlette authentication
|
||||
}
|
||||
)
|
||||
|
||||
@router.post("/{model_name}/new")
|
||||
@require_admin(redirect_url="/auth/login")
|
||||
async def create_record(
|
||||
request: Request,
|
||||
model_name: str,
|
||||
@@ -254,6 +230,7 @@ async def create_record(
|
||||
return RedirectResponse(f"/admin/{model_name}", status_code=303)
|
||||
|
||||
@router.get("/{model_name}/{record_id}", response_class=HTMLResponse)
|
||||
@require_admin(redirect_url="/auth/login")
|
||||
async def edit_record_form(
|
||||
request: Request,
|
||||
model_name: str,
|
||||
@@ -261,16 +238,6 @@ async def edit_record_form(
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""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:
|
||||
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
||||
|
||||
@@ -311,11 +278,12 @@ async def edit_record_form(
|
||||
"record": record_data,
|
||||
"foreign_key_options": foreign_key_options,
|
||||
"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}")
|
||||
@require_admin(redirect_url="/auth/login")
|
||||
async def update_record(
|
||||
request: Request,
|
||||
model_name: str,
|
||||
@@ -364,6 +332,7 @@ async def update_record(
|
||||
return RedirectResponse(f"/admin/{model_name}", status_code=303)
|
||||
|
||||
@router.get("/{model_name}/{record_id}/delete")
|
||||
@require_admin(redirect_url="/auth/login")
|
||||
async def delete_record(
|
||||
request: Request,
|
||||
model_name: str,
|
||||
|
||||
+114
-36
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Dict, Any
|
||||
import secrets
|
||||
import os
|
||||
import uuid
|
||||
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta
|
||||
|
||||
from ..db import get_db
|
||||
from ..models import User
|
||||
from ..auth.oauth import authentik_oauth
|
||||
from ..auth.oauth import oauth_manager
|
||||
from ..templates_config import templates
|
||||
from ..security import verify_password, get_password_hash
|
||||
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:
|
||||
message = "You are already logged in."
|
||||
|
||||
# Get available OAuth providers for the login screen
|
||||
oauth_providers = oauth_manager.get_available_providers()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"auth/login.html",
|
||||
{"request": request, "error": error, "message": message,
|
||||
"show_oauth": True, "oauth_provider_name": "Authentik",
|
||||
"user": user} # Add user to context
|
||||
{
|
||||
"request": request,
|
||||
"error": error,
|
||||
"message": message,
|
||||
"user": user,
|
||||
"oauth_providers": oauth_providers
|
||||
}
|
||||
)
|
||||
|
||||
@router.post("/login", response_class=HTMLResponse)
|
||||
@@ -71,8 +78,7 @@ async def login_post(
|
||||
{
|
||||
"request": request,
|
||||
"error": "Please verify your email address before logging in",
|
||||
"show_oauth": True,
|
||||
"oauth_provider_name": "Authentik",
|
||||
"oauth_providers": oauth_manager.get_available_providers(),
|
||||
"unverified_user_id": user.id,
|
||||
"unverified_email": user.email
|
||||
}
|
||||
@@ -85,8 +91,7 @@ async def login_post(
|
||||
{
|
||||
"request": request,
|
||||
"error": error,
|
||||
"show_oauth": True,
|
||||
"oauth_provider_name": "Authentik"
|
||||
"oauth_providers": oauth_manager.get_available_providers()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -317,21 +322,21 @@ async def resend_verification(
|
||||
status_code=HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
@router.get("/oauth-login")
|
||||
async def oauth_login(request: Request):
|
||||
"""Start the OAuth login flow"""
|
||||
@router.get("/oauth-login/{provider_id}")
|
||||
async def oauth_login(request: Request, provider_id: str):
|
||||
"""Start the OAuth login flow for the specified provider"""
|
||||
# Generate the redirect URI
|
||||
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:
|
||||
# Set a session ID to validate the callback
|
||||
if "session_id" not in request.session:
|
||||
request.session["session_id"] = str(uuid.uuid4())
|
||||
|
||||
# Get the authorization URL - make sure to await it
|
||||
auth_url = await authentik_oauth.get_login_url(request, redirect_uri)
|
||||
# Get the authorization URL
|
||||
auth_url = await oauth_manager.get_login_url(request, provider_id, redirect_uri)
|
||||
|
||||
# Redirect to the authorization URL
|
||||
return RedirectResponse(auth_url)
|
||||
@@ -342,9 +347,16 @@ async def oauth_login(request: Request):
|
||||
status_code=HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
@router.get("/oauth-callback")
|
||||
async def oauth_callback(request: Request, code: Optional[str] = None, state: Optional[str] = None, error: Optional[str] = None, db: Session = Depends(get_db)):
|
||||
"""Handle the OAuth callback"""
|
||||
@router.get("/oauth-callback/{provider_id}")
|
||||
async def 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:
|
||||
return RedirectResponse(
|
||||
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
|
||||
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:
|
||||
# Get user info from the provider
|
||||
user_info = await authentik_oauth.get_user_info(request, redirect_uri, code)
|
||||
# Get normalized user info from the provider
|
||||
user_info = await oauth_manager.get_user_info(request, provider_id, redirect_uri, code)
|
||||
|
||||
if not user_info:
|
||||
return RedirectResponse(
|
||||
@@ -371,24 +383,43 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
|
||||
status_code=HTTP_303_SEE_OTHER
|
||||
)
|
||||
|
||||
# Extract user details from OAuth info
|
||||
sub = user_info.get("sub", "")
|
||||
email = user_info.get("email", "")
|
||||
name = user_info.get("preferred_username", "") or user_info.get("name", "") or email.split("@")[0]
|
||||
picture = user_info.get("picture", None)
|
||||
# Extract user details from the normalized OAuth info
|
||||
provider_user_id = user_info.get("id")
|
||||
email = user_info.get("email")
|
||||
name = user_info.get("name") or (email.split("@")[0] if email else f"user_{provider_id}")
|
||||
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()
|
||||
|
||||
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
|
||||
user = User(
|
||||
username=name,
|
||||
username=username,
|
||||
email=email,
|
||||
oauth_id=sub,
|
||||
oauth_id=provider_user_id,
|
||||
is_oauth_user=True,
|
||||
oauth_provider="authentik",
|
||||
oauth_provider=provider_id,
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
picture=picture,
|
||||
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
|
||||
if not user.is_oauth_user:
|
||||
user.is_oauth_user = True
|
||||
user.oauth_id = sub
|
||||
user.oauth_provider = "authentik"
|
||||
user.oauth_id = provider_user_id
|
||||
|
||||
# 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:
|
||||
user.picture = picture
|
||||
|
||||
# Update last login time
|
||||
user.last_login = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
# 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["is_authenticated"] = True
|
||||
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:
|
||||
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
|
||||
)
|
||||
|
||||
# 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")
|
||||
async def logout(request: Request):
|
||||
"""Log out the user"""
|
||||
|
||||
@@ -18,6 +18,7 @@ from ..schemas import TeamCreate
|
||||
from ..templates_config import templates
|
||||
from ..utils.auth import get_current_user, is_team_captain
|
||||
from ..utils.mail import send_team_join_request_notification, send_join_request_response
|
||||
from ..auth.permissions import require_auth, require_team_captain
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
@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
@@ -28,15 +28,18 @@ services:
|
||||
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"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
# Use environment variable from .env instead of hard-coding
|
||||
|
||||
# Base URL
|
||||
LEAGUELEDGER_BASE_URL: ${LEAGUELEDGER_BASE_URL:-http://localhost:8000}
|
||||
# Email configuration for Mailhog
|
||||
|
||||
# Email configuration for Mailpit
|
||||
MAIL_USERNAME: ""
|
||||
MAIL_PASSWORD: ""
|
||||
MAIL_FROM: "noreply@leagueledger.net"
|
||||
@@ -47,6 +50,37 @@ services:
|
||||
MAIL_SSL_TLS: "False"
|
||||
MAIL_USE_CREDENTIALS: "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
|
||||
ports:
|
||||
- "8000:8000"
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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 © 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
|
||||
@@ -39,3 +39,6 @@ Pillow>=9.0.0
|
||||
# PDF generation for QR code sheets
|
||||
reportlab>=3.6.12
|
||||
babel>=2.12.1
|
||||
|
||||
# JSON handling for additional OAuth providers storage
|
||||
simplejson>=3.19.2
|
||||
|
||||
Reference in New Issue
Block a user