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,23 +1,120 @@
|
|||||||
import os
|
import os
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from httpx_oauth.clients.google import GoogleOAuth2
|
||||||
|
from httpx_oauth.clients.github import GitHubOAuth2
|
||||||
|
from httpx_oauth.clients.facebook import FacebookOAuth2
|
||||||
|
from httpx_oauth.clients.discord import DiscordOAuth2
|
||||||
|
# LinkedIn OAuth client
|
||||||
|
from httpx_oauth.clients.linkedin import LinkedInOAuth2
|
||||||
|
# Microsoft client import may not be available in all httpx_oauth versions
|
||||||
|
try:
|
||||||
|
from httpx_oauth.clients.microsoft import MicrosoftOAuth2
|
||||||
|
MICROSOFT_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
# Custom implementation if the package doesn't have it
|
||||||
|
from httpx_oauth.oauth2 import OAuth2, GetAccessTokenError
|
||||||
|
MICROSOFT_AVAILABLE = False
|
||||||
|
|
||||||
|
# Basic Microsoft OAuth2 implementation if not available in the library
|
||||||
|
class MicrosoftOAuth2(OAuth2):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
tenant: str = "common",
|
||||||
|
):
|
||||||
|
super().__init__(
|
||||||
|
client_id=client_id,
|
||||||
|
client_secret=client_secret,
|
||||||
|
authorize_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
|
||||||
|
access_token_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
|
||||||
|
refresh_token_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
|
||||||
|
base_scopes=["openid", "profile", "email"],
|
||||||
|
)
|
||||||
|
|
||||||
from httpx_oauth.clients.openid import OpenID
|
from httpx_oauth.clients.openid import OpenID
|
||||||
from httpx_oauth.oauth2 import GetAccessTokenError
|
from httpx_oauth.oauth2 import GetAccessTokenError
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
from starlette.responses import RedirectResponse
|
from starlette.responses import RedirectResponse
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, List, Type
|
||||||
import json
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
class AuthentikOAuth:
|
class OAuthProvider(ABC):
|
||||||
|
"""Base class for all OAuth providers"""
|
||||||
|
|
||||||
|
# Provider identifier - should be unique and lowercase
|
||||||
|
provider_id = "base"
|
||||||
|
|
||||||
|
# Display name for UI
|
||||||
|
display_name = "Base Provider"
|
||||||
|
|
||||||
|
# Icon class (for UI rendering, e.g. Font Awesome)
|
||||||
|
icon_class = "fas fa-sign-in-alt"
|
||||||
|
|
||||||
|
# Default button color (hex or valid CSS color name)
|
||||||
|
button_color = "#333333"
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.client_id = os.getenv("AUTHENTIK_CLIENT_ID", "dRXLBdTdG6JSHqkcM0ZQBPwBVMBrG6SF32LZ1XAT")
|
|
||||||
self.client_secret = os.getenv("AUTHENTIK_CLIENT_SECRET",
|
|
||||||
"hn1aKecLeYj1tVc7QtsavrWjSOF4t7Ty1akVTmUqvIFJF1y0H3Myv7InUxAX6E2GLpMxxhhZZ2aUSJ9VEQz7zGcMbgUeMStxx2U7bEQxmuOGjZf0XJbOBGjdwGZYJlz7")
|
|
||||||
self.config_url = os.getenv("AUTHENTIK_CONFIG_URL",
|
|
||||||
"https://authentik.hosterra.net/application/o/leagueledger/.well-known/openid-configuration")
|
|
||||||
self.client = None
|
self.client = None
|
||||||
self.initialize_client()
|
self.initialize_client()
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def initialize_client(self):
|
||||||
|
"""Initialize the specific OAuth client"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
|
||||||
|
"""Get the authorization URL for this provider"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
|
||||||
|
"""Get user information from the provider"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Normalize provider-specific user data into a standard format
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with standard fields:
|
||||||
|
- id: Unique identifier from provider
|
||||||
|
- email: User email (if available)
|
||||||
|
- name: User's full/display name
|
||||||
|
- first_name: User's first name (if available)
|
||||||
|
- last_name: User's last name (if available)
|
||||||
|
- picture: URL to user's avatar/picture (if available)
|
||||||
|
- raw: The original user_info dict
|
||||||
|
"""
|
||||||
|
# Default implementation - should be overridden by providers
|
||||||
|
return {
|
||||||
|
"id": str(user_info.get("id", "")),
|
||||||
|
"email": user_info.get("email"),
|
||||||
|
"name": user_info.get("name"),
|
||||||
|
"first_name": user_info.get("given_name"),
|
||||||
|
"last_name": user_info.get("family_name"),
|
||||||
|
"picture": user_info.get("picture"),
|
||||||
|
"raw": user_info
|
||||||
|
}
|
||||||
|
|
||||||
|
class AuthentikOAuth(OAuthProvider):
|
||||||
|
"""Authentik OpenID Connect OAuth provider"""
|
||||||
|
|
||||||
|
provider_id = "authentik"
|
||||||
|
display_name = "Authentik"
|
||||||
|
icon_class = "fas fa-shield-alt"
|
||||||
|
button_color = "#fd4b2d"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client_id = os.getenv("AUTHENTIK_CLIENT_ID", "yourAuthentikClientID")
|
||||||
|
self.client_secret = os.getenv("AUTHENTIK_CLIENT_SECRET", "yourAuthentikClientSecret")
|
||||||
|
self.config_url = os.getenv("AUTHENTIK_CONFIG_URL",
|
||||||
|
"https://authentik.example.com/application/o/leagueledger/.well-known/openid-configuration")
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
def initialize_client(self):
|
def initialize_client(self):
|
||||||
try:
|
try:
|
||||||
self.client = OpenID(
|
self.client = OpenID(
|
||||||
@@ -94,5 +191,701 @@ class AuthentikOAuth:
|
|||||||
print(f"Error getting user info: {str(e)}")
|
print(f"Error getting user info: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
# Instantiate the OAuth client for the application to use
|
class GoogleOAuth(OAuthProvider):
|
||||||
authentik_oauth = AuthentikOAuth()
|
"""Google OAuth provider implementation"""
|
||||||
|
|
||||||
|
provider_id = "google"
|
||||||
|
display_name = "Google"
|
||||||
|
icon_class = "fab fa-google"
|
||||||
|
button_color = "#4285F4"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client_id = os.getenv("GOOGLE_CLIENT_ID", "")
|
||||||
|
self.client_secret = os.getenv("GOOGLE_CLIENT_SECRET", "")
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def initialize_client(self):
|
||||||
|
if not self.client_id or not self.client_secret:
|
||||||
|
self.client = None
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client = GoogleOAuth2(
|
||||||
|
client_id=self.client_id,
|
||||||
|
client_secret=self.client_secret
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing Google OAuth client: {str(e)}")
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="Google OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authorization_url = await self.client.get_authorization_url(
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
scope=["openid", "email", "profile"],
|
||||||
|
state=str(request.session.get("session_id", "")),
|
||||||
|
extras_params={"access_type": "offline", "prompt": "select_account"}
|
||||||
|
)
|
||||||
|
return authorization_url
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting Google authorization URL: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="Google OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Exchange code for token
|
||||||
|
token = await self.client.get_access_token(
|
||||||
|
code=code,
|
||||||
|
redirect_uri=redirect_uri
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = token.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Could not get Google access token")
|
||||||
|
|
||||||
|
# Get user info from Google
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
response = await client.get(
|
||||||
|
"https://www.googleapis.com/oauth2/v3/userinfo",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error fetching Google user info: {response.text}")
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except GetAccessTokenError as e:
|
||||||
|
error_description = e.args[0]
|
||||||
|
raise HTTPException(status_code=400, detail=f"Google OAuth error: {error_description}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting Google user info: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
# Google specific normalization
|
||||||
|
return {
|
||||||
|
"id": user_info.get("sub", ""),
|
||||||
|
"email": user_info.get("email"),
|
||||||
|
"name": user_info.get("name"),
|
||||||
|
"first_name": user_info.get("given_name"),
|
||||||
|
"last_name": user_info.get("family_name"),
|
||||||
|
"picture": user_info.get("picture"),
|
||||||
|
"raw": user_info
|
||||||
|
}
|
||||||
|
|
||||||
|
class GitHubOAuth(OAuthProvider):
|
||||||
|
"""GitHub OAuth provider implementation"""
|
||||||
|
|
||||||
|
provider_id = "github"
|
||||||
|
display_name = "GitHub"
|
||||||
|
icon_class = "fab fa-github"
|
||||||
|
button_color = "#171515"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client_id = os.getenv("GITHUB_CLIENT_ID", "")
|
||||||
|
self.client_secret = os.getenv("GITHUB_CLIENT_SECRET", "")
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def initialize_client(self):
|
||||||
|
if not self.client_id or not self.client_secret:
|
||||||
|
self.client = None
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client = GitHubOAuth2(
|
||||||
|
client_id=self.client_id,
|
||||||
|
client_secret=self.client_secret
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing GitHub OAuth client: {str(e)}")
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="GitHub OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authorization_url = await self.client.get_authorization_url(
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
scope=["user:email"],
|
||||||
|
state=str(request.session.get("session_id", "")),
|
||||||
|
)
|
||||||
|
return authorization_url
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting GitHub authorization URL: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="GitHub OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Exchange code for token
|
||||||
|
token = await self.client.get_access_token(
|
||||||
|
code=code,
|
||||||
|
redirect_uri=redirect_uri
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = token.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Could not get GitHub access token")
|
||||||
|
|
||||||
|
# Get user info from GitHub
|
||||||
|
user_data = {}
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"token {access_token}",
|
||||||
|
"Accept": "application/vnd.github.v3+json"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get user profile
|
||||||
|
user_response = await client.get(
|
||||||
|
"https://api.github.com/user",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if user_response.status_code != 200:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error fetching GitHub user info: {user_response.text}")
|
||||||
|
|
||||||
|
user_data = user_response.json()
|
||||||
|
|
||||||
|
# Get user emails
|
||||||
|
email_response = await client.get(
|
||||||
|
"https://api.github.com/user/emails",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if email_response.status_code == 200:
|
||||||
|
emails = email_response.json()
|
||||||
|
primary_email = next((email for email in emails if email.get("primary") is True), None)
|
||||||
|
if primary_email:
|
||||||
|
user_data["email"] = primary_email.get("email")
|
||||||
|
|
||||||
|
return user_data
|
||||||
|
|
||||||
|
except GetAccessTokenError as e:
|
||||||
|
error_description = e.args[0]
|
||||||
|
raise HTTPException(status_code=400, detail=f"GitHub OAuth error: {error_description}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting GitHub user info: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
# GitHub specific normalization
|
||||||
|
name_parts = (user_info.get("name") or "").split(" ", 1)
|
||||||
|
first_name = name_parts[0] if name_parts else ""
|
||||||
|
last_name = name_parts[1] if len(name_parts) > 1 else ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(user_info.get("id", "")),
|
||||||
|
"email": user_info.get("email"),
|
||||||
|
"name": user_info.get("name") or user_info.get("login"),
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"picture": user_info.get("avatar_url"),
|
||||||
|
"raw": user_info
|
||||||
|
}
|
||||||
|
|
||||||
|
class FacebookOAuth(OAuthProvider):
|
||||||
|
"""Facebook OAuth provider implementation"""
|
||||||
|
|
||||||
|
provider_id = "facebook"
|
||||||
|
display_name = "Facebook"
|
||||||
|
icon_class = "fab fa-facebook"
|
||||||
|
button_color = "#1877F2"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client_id = os.getenv("FACEBOOK_CLIENT_ID", "")
|
||||||
|
self.client_secret = os.getenv("FACEBOOK_CLIENT_SECRET", "")
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def initialize_client(self):
|
||||||
|
if not self.client_id or not self.client_secret:
|
||||||
|
self.client = None
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client = FacebookOAuth2(
|
||||||
|
client_id=self.client_id,
|
||||||
|
client_secret=self.client_secret
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing Facebook OAuth client: {str(e)}")
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="Facebook OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authorization_url = await self.client.get_authorization_url(
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
scope=["email", "public_profile"],
|
||||||
|
state=str(request.session.get("session_id", ""))
|
||||||
|
)
|
||||||
|
return authorization_url
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting Facebook authorization URL: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="Facebook OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Exchange code for token
|
||||||
|
token = await self.client.get_access_token(
|
||||||
|
code=code,
|
||||||
|
redirect_uri=redirect_uri
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = token.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Could not get Facebook access token")
|
||||||
|
|
||||||
|
# Get user info from Facebook
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://graph.facebook.com/me",
|
||||||
|
params={
|
||||||
|
"access_token": access_token,
|
||||||
|
"fields": "id,name,email,first_name,last_name,picture"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error fetching Facebook user info: {response.text}")
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except GetAccessTokenError as e:
|
||||||
|
error_description = e.args[0]
|
||||||
|
raise HTTPException(status_code=400, detail=f"Facebook OAuth error: {error_description}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting Facebook user info: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
# Facebook specific normalization
|
||||||
|
picture_url = None
|
||||||
|
if "picture" in user_info and "data" in user_info["picture"]:
|
||||||
|
picture_url = user_info["picture"]["data"].get("url")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": user_info.get("id", ""),
|
||||||
|
"email": user_info.get("email"),
|
||||||
|
"name": user_info.get("name"),
|
||||||
|
"first_name": user_info.get("first_name"),
|
||||||
|
"last_name": user_info.get("last_name"),
|
||||||
|
"picture": picture_url,
|
||||||
|
"raw": user_info
|
||||||
|
}
|
||||||
|
|
||||||
|
class MicrosoftOAuth(OAuthProvider):
|
||||||
|
"""Microsoft OAuth provider implementation"""
|
||||||
|
|
||||||
|
provider_id = "microsoft"
|
||||||
|
display_name = "Microsoft"
|
||||||
|
icon_class = "fab fa-microsoft"
|
||||||
|
button_color = "#00A4EF"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client_id = os.getenv("MICROSOFT_CLIENT_ID", "")
|
||||||
|
self.client_secret = os.getenv("MICROSOFT_CLIENT_SECRET", "")
|
||||||
|
self.tenant = os.getenv("MICROSOFT_TENANT", "common") # "common" for multi-tenant apps
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def initialize_client(self):
|
||||||
|
if not self.client_id or not self.client_secret:
|
||||||
|
self.client = None
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client = MicrosoftOAuth2(
|
||||||
|
client_id=self.client_id,
|
||||||
|
client_secret=self.client_secret,
|
||||||
|
tenant=self.tenant
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing Microsoft OAuth client: {str(e)}")
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="Microsoft OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authorization_url = await self.client.get_authorization_url(
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
scope=["User.Read", "email", "profile", "openid"],
|
||||||
|
state=str(request.session.get("session_id", ""))
|
||||||
|
)
|
||||||
|
return authorization_url
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting Microsoft authorization URL: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="Microsoft OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Exchange code for token
|
||||||
|
token = await self.client.get_access_token(
|
||||||
|
code=code,
|
||||||
|
redirect_uri=redirect_uri
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = token.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Could not get Microsoft access token")
|
||||||
|
|
||||||
|
# Get user info from Microsoft Graph API
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
response = await client.get(
|
||||||
|
"https://graph.microsoft.com/v1.0/me",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error fetching Microsoft user info: {response.text}")
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except GetAccessTokenError as e:
|
||||||
|
error_description = e.args[0]
|
||||||
|
raise HTTPException(status_code=400, detail=f"Microsoft OAuth error: {error_description}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting Microsoft user info: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": user_info.get("id", ""),
|
||||||
|
"email": user_info.get("mail") or user_info.get("userPrincipalName"),
|
||||||
|
"name": user_info.get("displayName"),
|
||||||
|
"first_name": user_info.get("givenName"),
|
||||||
|
"last_name": user_info.get("surname"),
|
||||||
|
"picture": None, # Microsoft Graph doesn't include photo in basic profile
|
||||||
|
"raw": user_info
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiscordOAuth(OAuthProvider):
|
||||||
|
"""Discord OAuth provider implementation"""
|
||||||
|
|
||||||
|
provider_id = "discord"
|
||||||
|
display_name = "Discord"
|
||||||
|
icon_class = "fab fa-discord"
|
||||||
|
button_color = "#5865F2"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client_id = os.getenv("DISCORD_CLIENT_ID", "")
|
||||||
|
self.client_secret = os.getenv("DISCORD_CLIENT_SECRET", "")
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def initialize_client(self):
|
||||||
|
if not self.client_id or not self.client_secret:
|
||||||
|
self.client = None
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client = DiscordOAuth2(
|
||||||
|
client_id=self.client_id,
|
||||||
|
client_secret=self.client_secret
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing Discord OAuth client: {str(e)}")
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="Discord OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authorization_url = await self.client.get_authorization_url(
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
scope=["identify", "email"],
|
||||||
|
state=str(request.session.get("session_id", ""))
|
||||||
|
)
|
||||||
|
return authorization_url
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting Discord authorization URL: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="Discord OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Exchange code for token
|
||||||
|
token = await self.client.get_access_token(
|
||||||
|
code=code,
|
||||||
|
redirect_uri=redirect_uri
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = token.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Could not get Discord access token")
|
||||||
|
|
||||||
|
# Get user info from Discord API
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
response = await client.get(
|
||||||
|
"https://discord.com/api/users/@me",
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error fetching Discord user info: {response.text}")
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
except GetAccessTokenError as e:
|
||||||
|
error_description = e.args[0]
|
||||||
|
raise HTTPException(status_code=400, detail=f"Discord OAuth error: {error_description}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting Discord user info: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
avatar_url = None
|
||||||
|
if user_info.get("avatar"):
|
||||||
|
user_id = user_info.get("id")
|
||||||
|
avatar_hash = user_info.get("avatar")
|
||||||
|
avatar_url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png"
|
||||||
|
|
||||||
|
# Discord doesn't split names, just has a username
|
||||||
|
return {
|
||||||
|
"id": user_info.get("id", ""),
|
||||||
|
"email": user_info.get("email"),
|
||||||
|
"name": user_info.get("username") or user_info.get("global_name"),
|
||||||
|
"first_name": user_info.get("username", "").split("#", 1)[0],
|
||||||
|
"last_name": "",
|
||||||
|
"picture": avatar_url,
|
||||||
|
"raw": user_info
|
||||||
|
}
|
||||||
|
|
||||||
|
class LinkedInOAuth(OAuthProvider):
|
||||||
|
"""LinkedIn OAuth provider implementation using OpenID Connect"""
|
||||||
|
|
||||||
|
provider_id = "linkedin"
|
||||||
|
display_name = "LinkedIn"
|
||||||
|
icon_class = "fab fa-linkedin"
|
||||||
|
button_color = "#0077B5"
|
||||||
|
|
||||||
|
# LinkedIn OIDC endpoints
|
||||||
|
AUTHORIZATION_URL = "https://www.linkedin.com/oauth/v2/authorization"
|
||||||
|
TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
|
||||||
|
USERINFO_URL = "https://api.linkedin.com/v2/userinfo"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client_id = os.getenv("LINKEDIN_CLIENT_ID", "")
|
||||||
|
self.client_secret = os.getenv("LINKEDIN_CLIENT_SECRET", "")
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
def initialize_client(self):
|
||||||
|
if not self.client_id or not self.client_secret:
|
||||||
|
self.client = None
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a custom OAuth2 client for LinkedIn OpenID Connect
|
||||||
|
from httpx_oauth.oauth2 import OAuth2
|
||||||
|
self.client = OAuth2(
|
||||||
|
client_id=self.client_id,
|
||||||
|
client_secret=self.client_secret,
|
||||||
|
authorize_endpoint=self.AUTHORIZATION_URL,
|
||||||
|
access_token_endpoint=self.TOKEN_URL,
|
||||||
|
refresh_token_endpoint=self.TOKEN_URL,
|
||||||
|
base_scopes=["openid", "profile", "email"]
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error initializing LinkedIn OAuth client: {str(e)}")
|
||||||
|
self.client = None
|
||||||
|
|
||||||
|
async def get_login_url(self, request: Request, redirect_uri: str) -> str:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="LinkedIn OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
authorization_url = await self.client.get_authorization_url(
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
|
# Using the required OpenID Connect scopes
|
||||||
|
scope=["openid", "profile", "email"],
|
||||||
|
state=str(request.session.get("session_id", ""))
|
||||||
|
)
|
||||||
|
return authorization_url
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting LinkedIn authorization URL: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]:
|
||||||
|
if not self.client:
|
||||||
|
self.initialize_client()
|
||||||
|
|
||||||
|
if not self.client:
|
||||||
|
raise HTTPException(status_code=500, detail="LinkedIn OAuth client could not be initialized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Exchange code for token
|
||||||
|
token = await self.client.get_access_token(
|
||||||
|
code=code,
|
||||||
|
redirect_uri=redirect_uri
|
||||||
|
)
|
||||||
|
|
||||||
|
access_token = token.get("access_token")
|
||||||
|
id_token = token.get("id_token") # JWT token containing basic user info
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
raise HTTPException(status_code=400, detail="Could not get LinkedIn access token")
|
||||||
|
|
||||||
|
# Get user info from LinkedIn UserInfo endpoint (OIDC standard endpoint)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
response = await client.get(
|
||||||
|
self.USERINFO_URL,
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error fetching LinkedIn user info: {response.text}")
|
||||||
|
|
||||||
|
user_info = response.json()
|
||||||
|
|
||||||
|
# The user info from the OIDC userinfo endpoint should already contain the
|
||||||
|
# email if requested in the scope, no need for a separate call
|
||||||
|
|
||||||
|
return user_info
|
||||||
|
|
||||||
|
except GetAccessTokenError as e:
|
||||||
|
error_description = e.args[0]
|
||||||
|
raise HTTPException(status_code=400, detail=f"LinkedIn OAuth error: {error_description}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting LinkedIn user info: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}")
|
||||||
|
|
||||||
|
def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
# LinkedIn OpenID Connect response normalization
|
||||||
|
return {
|
||||||
|
"id": user_info.get("sub", ""), # 'sub' is the standard OIDC subject identifier
|
||||||
|
"email": user_info.get("email"),
|
||||||
|
"name": user_info.get("name"),
|
||||||
|
"first_name": user_info.get("given_name"),
|
||||||
|
"last_name": user_info.get("family_name"),
|
||||||
|
"picture": user_info.get("picture"),
|
||||||
|
"raw": user_info
|
||||||
|
}
|
||||||
|
|
||||||
|
class OAuthManager:
|
||||||
|
"""
|
||||||
|
Manager class for handling multiple OAuth providers
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.providers: Dict[str, OAuthProvider] = {}
|
||||||
|
self.register_default_providers()
|
||||||
|
|
||||||
|
def register_default_providers(self):
|
||||||
|
"""Register all available providers"""
|
||||||
|
self.register_provider(AuthentikOAuth())
|
||||||
|
self.register_provider(GoogleOAuth())
|
||||||
|
self.register_provider(FacebookOAuth())
|
||||||
|
self.register_provider(GitHubOAuth())
|
||||||
|
self.register_provider(MicrosoftOAuth())
|
||||||
|
self.register_provider(DiscordOAuth())
|
||||||
|
self.register_provider(LinkedInOAuth())
|
||||||
|
|
||||||
|
def register_provider(self, provider: OAuthProvider):
|
||||||
|
"""Register a new provider"""
|
||||||
|
self.providers[provider.provider_id] = provider
|
||||||
|
|
||||||
|
def get_provider(self, provider_id: str) -> Optional[OAuthProvider]:
|
||||||
|
"""Get a provider by ID"""
|
||||||
|
return self.providers.get(provider_id)
|
||||||
|
|
||||||
|
def get_available_providers(self) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Get list of available providers (those with credentials configured)
|
||||||
|
Returns a list of provider details for UI rendering
|
||||||
|
"""
|
||||||
|
available_providers = []
|
||||||
|
|
||||||
|
for provider_id, provider in self.providers.items():
|
||||||
|
if provider.client is not None:
|
||||||
|
available_providers.append({
|
||||||
|
"id": provider.provider_id,
|
||||||
|
"name": provider.display_name,
|
||||||
|
"icon": provider.icon_class,
|
||||||
|
"color": provider.button_color
|
||||||
|
})
|
||||||
|
|
||||||
|
return available_providers
|
||||||
|
|
||||||
|
async def get_login_url(self, request: Request, provider_id: str, redirect_uri: str) -> str:
|
||||||
|
"""Get login URL for a specific provider"""
|
||||||
|
provider = self.get_provider(provider_id)
|
||||||
|
if not provider:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_id}")
|
||||||
|
|
||||||
|
return await provider.get_login_url(request, redirect_uri)
|
||||||
|
|
||||||
|
async def get_user_info(self, request: Request, provider_id: str,
|
||||||
|
redirect_uri: str, code: str) -> Dict[str, Any]:
|
||||||
|
"""Get user info from a specific provider"""
|
||||||
|
provider = self.get_provider(provider_id)
|
||||||
|
if not provider:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_id}")
|
||||||
|
|
||||||
|
# Get raw user info
|
||||||
|
user_info = await provider.get_user_info(request, redirect_uri, code)
|
||||||
|
|
||||||
|
# Normalize it
|
||||||
|
return provider.get_normalized_user_data(user_info)
|
||||||
|
|
||||||
|
# Create the OAuth manager for the application to use
|
||||||
|
oauth_manager = OAuthManager()
|
||||||
|
|
||||||
|
# For backwards compatibility
|
||||||
|
authentik_oauth = oauth_manager.get_provider("authentik")
|
||||||
|
|||||||
@@ -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 passlib.context import CryptContext
|
||||||
|
|
||||||
from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event
|
from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event
|
||||||
from .db import SessionLocal
|
from .db import SessionLocal, engine
|
||||||
|
from .db_migrations import run_migrations
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
@@ -25,6 +26,14 @@ def table_has_column(engine, table_name, column_name):
|
|||||||
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
return column_name in columns
|
return column_name in columns
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Initialize the database, applying migrations and seeding data."""
|
||||||
|
# First, run any needed migrations
|
||||||
|
run_migrations(engine)
|
||||||
|
|
||||||
|
# Then proceed with seeding if needed
|
||||||
|
seed_db()
|
||||||
|
|
||||||
def seed_db():
|
def seed_db():
|
||||||
"""Seed the database with test data."""
|
"""Seed the database with test data."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -288,4 +297,4 @@ def seed_db():
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
seed_db()
|
init_db()
|
||||||
|
|||||||
+86
-1
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import text, inspect
|
from sqlalchemy import text, inspect, Column, String, JSON, MetaData, Table
|
||||||
from .db import engine
|
from .db import engine
|
||||||
|
|
||||||
def table_exists(conn, table_name):
|
def table_exists(conn, table_name):
|
||||||
@@ -90,3 +90,88 @@ def apply_migrations():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error applying migrations: {str(e)}")
|
print(f"Error applying migrations: {str(e)}")
|
||||||
|
|
||||||
|
def run_migrations(engine):
|
||||||
|
"""
|
||||||
|
Run database migrations that can't be handled by SQLAlchemy's create_all()
|
||||||
|
"""
|
||||||
|
# Create a MetaData object
|
||||||
|
metadata = MetaData()
|
||||||
|
metadata.bind = engine
|
||||||
|
connection = engine.connect()
|
||||||
|
|
||||||
|
try:
|
||||||
|
print("Running migrations...")
|
||||||
|
|
||||||
|
# Check if the columns already exist before adding them
|
||||||
|
# Add additional_oauth_providers column if it doesn't exist
|
||||||
|
add_oauth_providers_column(connection)
|
||||||
|
|
||||||
|
# Add first_name and last_name columns if they don't exist
|
||||||
|
add_name_columns(connection)
|
||||||
|
|
||||||
|
print("Migrations completed successfully")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during migrations: {str(e)}")
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
|
|
||||||
|
def add_oauth_providers_column(connection):
|
||||||
|
"""Add additional_oauth_providers column to users table"""
|
||||||
|
try:
|
||||||
|
# Use database-agnostic way to check if column exists
|
||||||
|
inspector = inspect(engine)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('users')]
|
||||||
|
|
||||||
|
if 'additional_oauth_providers' not in columns:
|
||||||
|
print("Adding additional_oauth_providers column to users table")
|
||||||
|
|
||||||
|
# Add column with database-specific syntax
|
||||||
|
if engine.name == 'sqlite':
|
||||||
|
connection.execute(text("""
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN additional_oauth_providers JSON
|
||||||
|
"""))
|
||||||
|
else: # MySQL
|
||||||
|
connection.execute(text("""
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN additional_oauth_providers JSON NULL
|
||||||
|
"""))
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
else:
|
||||||
|
print("Column additional_oauth_providers already exists")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error adding additional_oauth_providers column: {str(e)}")
|
||||||
|
|
||||||
|
def add_name_columns(connection):
|
||||||
|
"""Add first_name and last_name columns to users table"""
|
||||||
|
try:
|
||||||
|
# Use database-agnostic way to check if columns exist
|
||||||
|
inspector = inspect(engine)
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('users')]
|
||||||
|
|
||||||
|
# Add first_name if needed
|
||||||
|
if 'first_name' not in columns:
|
||||||
|
print("Adding first_name column to users table")
|
||||||
|
connection.execute(text("""
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN first_name VARCHAR(50) NULL
|
||||||
|
"""))
|
||||||
|
connection.commit()
|
||||||
|
else:
|
||||||
|
print("Column first_name already exists")
|
||||||
|
|
||||||
|
# Add last_name if needed
|
||||||
|
if 'last_name' not in columns:
|
||||||
|
print("Adding last_name column to users table")
|
||||||
|
connection.execute(text("""
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN last_name VARCHAR(50) NULL
|
||||||
|
"""))
|
||||||
|
connection.commit()
|
||||||
|
else:
|
||||||
|
print("Column last_name already exists")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error adding name columns: {str(e)}")
|
||||||
|
|||||||
+17
-16
@@ -7,16 +7,17 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import logging
|
import logging
|
||||||
import contextlib
|
import contextlib
|
||||||
|
|
||||||
from .db import init_db, engine, get_db
|
from .db import engine, get_db
|
||||||
from . import models
|
from . import models
|
||||||
from .templates_config import templates
|
from .templates_config import templates
|
||||||
from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience
|
from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience
|
||||||
from .db_init import seed_db
|
from .db_init import init_db
|
||||||
from .db_migrations import apply_migrations
|
from .auth.middleware import SessionAuthBackend, on_auth_error
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
@@ -37,8 +38,18 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add SessionMiddleware with a secure secret key
|
# Get secret key for the session
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY", "a-very-secure-secret-key-for-development")
|
SECRET_KEY = os.getenv("SECRET_KEY", "a-very-secure-secret-key-for-development")
|
||||||
|
|
||||||
|
# Important: Order of middleware matters!
|
||||||
|
# First add AuthenticationMiddleware
|
||||||
|
app.add_middleware(
|
||||||
|
AuthenticationMiddleware,
|
||||||
|
backend=SessionAuthBackend(),
|
||||||
|
on_error=on_auth_error
|
||||||
|
)
|
||||||
|
|
||||||
|
# Then add SessionMiddleware (last added = first executed)
|
||||||
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
||||||
|
|
||||||
# Initialize database on startup
|
# Initialize database on startup
|
||||||
@@ -49,19 +60,9 @@ async def startup_db_client():
|
|||||||
# Import models to ensure they're registered with Base before initialization
|
# Import models to ensure they're registered with Base before initialization
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
# Create all tables first
|
# Initialize database (applies migrations and seeds data)
|
||||||
init_db()
|
init_db()
|
||||||
logger.info("Base tables created successfully")
|
logger.info("Database initialized and migrated successfully")
|
||||||
|
|
||||||
# Apply migrations to add additional columns and constraints
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
apply_migrations()
|
|
||||||
logger.info("Migrations applied successfully")
|
|
||||||
|
|
||||||
# Seed the database with test data if needed
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
seed_db()
|
|
||||||
logger.info("Database seeded successfully")
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Database initialization error: {str(e)}")
|
logger.error(f"Database initialization error: {str(e)}")
|
||||||
|
|||||||
+27
-3
@@ -1,14 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float, UniqueConstraint
|
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float, UniqueConstraint, JSON
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from starlette.authentication import BaseUser
|
||||||
|
|
||||||
# This Base should be the single source of truth
|
# This Base should be the single source of truth
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
class User(Base):
|
class User(Base, BaseUser):
|
||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
username = Column(String(50), unique=True, index=True, nullable=False)
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
@@ -29,7 +30,14 @@ class User(Base):
|
|||||||
# OAuth fields
|
# OAuth fields
|
||||||
is_oauth_user = Column(Boolean, default=False)
|
is_oauth_user = Column(Boolean, default=False)
|
||||||
oauth_id = Column(String(255), nullable=True)
|
oauth_id = Column(String(255), nullable=True)
|
||||||
oauth_provider = Column(String(50), nullable=True)
|
oauth_provider = Column(String(50), nullable=True) # Primary OAuth provider
|
||||||
|
|
||||||
|
# New field for multiple providers: store as JSON {provider_name: provider_user_id}
|
||||||
|
additional_oauth_providers = Column(JSON, nullable=True)
|
||||||
|
|
||||||
|
# Profile fields
|
||||||
|
first_name = Column(String(50), nullable=True)
|
||||||
|
last_name = Column(String(50), nullable=True)
|
||||||
picture = Column(String(255), nullable=True) # URL to profile picture
|
picture = Column(String(255), nullable=True) # URL to profile picture
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
@@ -38,6 +46,22 @@ class User(Base):
|
|||||||
events_attended = relationship("EventAttendee", back_populates="user")
|
events_attended = relationship("EventAttendee", back_populates="user")
|
||||||
owned_teams = relationship("Team", back_populates="owner")
|
owned_teams = relationship("Team", back_populates="owner")
|
||||||
|
|
||||||
|
# BaseUser interface implementation
|
||||||
|
@property
|
||||||
|
def is_authenticated(self) -> bool:
|
||||||
|
"""Return True as this user is authenticated."""
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_name(self) -> str:
|
||||||
|
"""Return the display name for this user."""
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
@property
|
||||||
|
def identity(self) -> str:
|
||||||
|
"""Return the identity of this user."""
|
||||||
|
return str(self.id)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<User {self.username}>"
|
return f"<User {self.username}>"
|
||||||
|
|
||||||
|
|||||||
@@ -87,27 +87,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OAuth login options -->
|
<!-- OAuth login options -->
|
||||||
{% if show_oauth %}
|
{% if oauth_providers and oauth_providers|length > 0 %}
|
||||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
|
||||||
<p class="text-center text-gray-600 mb-4">Or sign in with</p>
|
|
||||||
<div class="flex justify-center">
|
|
||||||
<a href="/auth/oauth-login" class="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full flex items-center justify-center">
|
|
||||||
<i class="fas fa-sign-in-alt mr-2"></i> Authentik
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="mt-6 pt-6 border-t border-gray-200">
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
<p class="text-center text-gray-600 mb-4">Or sign in with</p>
|
<p class="text-center text-gray-600 mb-4">Or sign in with</p>
|
||||||
|
|
||||||
|
<!-- For 2 or fewer providers, show them side by side -->
|
||||||
|
{% if oauth_providers|length <= 2 %}
|
||||||
<div class="flex justify-center space-x-4">
|
<div class="flex justify-center space-x-4">
|
||||||
<button class="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full disabled:opacity-50" disabled>
|
{% for provider in oauth_providers %}
|
||||||
<i class="fab fa-google mr-2"></i> Google
|
<a href="/auth/oauth-login/{{ provider.id }}"
|
||||||
</button>
|
class="flex items-center justify-center w-full py-2 px-4 rounded-md transition"
|
||||||
<button class="bg-gray-800 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full disabled:opacity-50" disabled>
|
style="background-color: {{ provider.color }}; color: white;">
|
||||||
<i class="fab fa-github mr-2"></i> GitHub
|
<i class="{{ provider.icon }} mr-2"></i> {{ provider.name }}
|
||||||
</button>
|
</a>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-center text-gray-500 text-xs mt-2">OAuth login currently disabled</p>
|
|
||||||
|
<!-- For more than 2 providers, stack them vertically -->
|
||||||
|
{% else %}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for provider in oauth_providers %}
|
||||||
|
<a href="/auth/oauth-login/{{ provider.id }}"
|
||||||
|
class="flex items-center justify-center w-full py-2 px-4 rounded-md transition"
|
||||||
|
style="background-color: {{ provider.color }}; color: white;">
|
||||||
|
<i class="{{ provider.icon }} mr-2"></i> {{ provider.name }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+31
-2
@@ -1,15 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Authentication utilities for LeagueLedger.
|
Authentication utilities for LeagueLedger.
|
||||||
|
This module provides backward compatibility with the existing code while leveraging
|
||||||
|
the new Starlette authentication system.
|
||||||
"""
|
"""
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import Request, Depends
|
from fastapi import Request, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..models import User, TeamMembership
|
from ..models import User, TeamMembership
|
||||||
|
from starlette.authentication import UnauthenticatedUser
|
||||||
|
|
||||||
async def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
|
async def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
|
||||||
"""
|
"""
|
||||||
Get the current authenticated user from the session.
|
Get the current authenticated user from the request.
|
||||||
|
Now uses request.user from Starlette authentication with fallback to session.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The FastAPI request object
|
request: The FastAPI request object
|
||||||
@@ -18,6 +22,11 @@ async def get_current_user(request: Request, db: Session = Depends(get_db)) -> O
|
|||||||
Returns:
|
Returns:
|
||||||
User object if authenticated, None otherwise
|
User object if authenticated, None otherwise
|
||||||
"""
|
"""
|
||||||
|
# First try to get user from Starlette authentication
|
||||||
|
if hasattr(request, "user") and request.user.is_authenticated:
|
||||||
|
return request.user
|
||||||
|
|
||||||
|
# Fallback to session-based authentication (for backward compatibility)
|
||||||
user_id = request.session.get("user_id")
|
user_id = request.session.get("user_id")
|
||||||
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
@@ -61,7 +70,7 @@ async def is_team_captain(
|
|||||||
is_captain = db.query(TeamMembership).filter(
|
is_captain = db.query(TeamMembership).filter(
|
||||||
TeamMembership.team_id == team_id,
|
TeamMembership.team_id == team_id,
|
||||||
TeamMembership.user_id == user.id,
|
TeamMembership.user_id == user.id,
|
||||||
TeamMembership.role == "captain"
|
TeamMembership.is_captain == True # Changed from role="captain" to is_captain=True
|
||||||
).first()
|
).first()
|
||||||
|
|
||||||
return bool(is_captain)
|
return bool(is_captain)
|
||||||
@@ -69,6 +78,7 @@ async def is_team_captain(
|
|||||||
async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool:
|
async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the current user is an admin.
|
Check if the current user is an admin.
|
||||||
|
Now checks Starlette auth scopes first.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: FastAPI request object
|
request: FastAPI request object
|
||||||
@@ -77,6 +87,11 @@ async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool:
|
|||||||
Returns:
|
Returns:
|
||||||
True if the user is an admin, False otherwise
|
True if the user is an admin, False otherwise
|
||||||
"""
|
"""
|
||||||
|
# Check for 'admin' scope in Starlette auth
|
||||||
|
if hasattr(request, "auth") and request.auth and "admin" in request.auth.scopes:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Fallback to user object check
|
||||||
user = await get_current_user(request, db)
|
user = await get_current_user(request, db)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
@@ -97,6 +112,11 @@ async def requires_login(request: Request, db: Session = Depends(get_db)) -> Opt
|
|||||||
"""
|
"""
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
# Check Starlette auth first
|
||||||
|
if hasattr(request, "user") and request.user.is_authenticated:
|
||||||
|
return request.user
|
||||||
|
|
||||||
|
# Fallback to session check
|
||||||
user = await get_current_user(request, db)
|
user = await get_current_user(request, db)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
@@ -121,6 +141,12 @@ async def requires_admin(request: Request, db: Session = Depends(get_db)) -> Use
|
|||||||
"""
|
"""
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
# Check for admin scope in Starlette auth
|
||||||
|
if hasattr(request, "auth") and request.auth and "admin" in request.auth.scopes:
|
||||||
|
if hasattr(request, "user") and request.user.is_authenticated:
|
||||||
|
return request.user
|
||||||
|
|
||||||
|
# Fallback to user object check
|
||||||
user = await get_current_user(request, db)
|
user = await get_current_user(request, db)
|
||||||
|
|
||||||
if not user or not user.is_admin:
|
if not user or not user.is_admin:
|
||||||
@@ -130,3 +156,6 @@ async def requires_admin(request: Request, db: Session = Depends(get_db)) -> Use
|
|||||||
)
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
# Note: For new code, consider using the decorators in app.auth.permissions instead
|
||||||
|
# of these dependency functions directly
|
||||||
|
|||||||
+13
-44
@@ -16,6 +16,7 @@ from ..models import (
|
|||||||
OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints
|
OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints
|
||||||
)
|
)
|
||||||
from ..templates_config import templates
|
from ..templates_config import templates
|
||||||
|
from ..auth.permissions import require_admin
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -74,25 +75,18 @@ def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
|
|||||||
return relationships
|
return relationships
|
||||||
|
|
||||||
@router.get("/", response_class=HTMLResponse)
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
@require_admin(redirect_url="/auth/login?next=/admin/")
|
||||||
async def admin_home(request: Request, db: Session = Depends(get_db)):
|
async def admin_home(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Admin dashboard home."""
|
"""Admin dashboard home."""
|
||||||
# Get user from session for navbar
|
# Since we're using Starlette's authentication, the user is now available in request.user
|
||||||
user = None
|
|
||||||
user_id = request.session.get("user_id")
|
|
||||||
if user_id:
|
|
||||||
user = db.query(User).get(user_id)
|
|
||||||
|
|
||||||
# Check admin status
|
|
||||||
if not user or not user.is_admin:
|
|
||||||
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
|
|
||||||
|
|
||||||
model_list = [(key, name) for key, (_, name) in MODELS.items()]
|
model_list = [(key, name) for key, (_, name) in MODELS.items()]
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"admin/index.html",
|
"admin/index.html",
|
||||||
{"request": request, "models": model_list, "user": user}
|
{"request": request, "models": model_list, "user": request.user}
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/{model_name}", response_class=HTMLResponse)
|
@router.get("/{model_name}", response_class=HTMLResponse)
|
||||||
|
@require_admin(redirect_url="/auth/login")
|
||||||
async def list_records(
|
async def list_records(
|
||||||
request: Request,
|
request: Request,
|
||||||
model_name: str,
|
model_name: str,
|
||||||
@@ -101,16 +95,6 @@ async def list_records(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""List records for a model with pagination."""
|
"""List records for a model with pagination."""
|
||||||
# Get user from session for navbar
|
|
||||||
user = None
|
|
||||||
user_id = request.session.get("user_id")
|
|
||||||
if user_id:
|
|
||||||
user = db.query(User).get(user_id)
|
|
||||||
|
|
||||||
# Check admin status
|
|
||||||
if not user or not user.is_admin:
|
|
||||||
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
|
|
||||||
|
|
||||||
if model_name not in MODELS:
|
if model_name not in MODELS:
|
||||||
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
||||||
|
|
||||||
@@ -150,27 +134,18 @@ async def list_records(
|
|||||||
"per_page": per_page,
|
"per_page": per_page,
|
||||||
"total_pages": total_pages,
|
"total_pages": total_pages,
|
||||||
"total_records": total_records,
|
"total_records": total_records,
|
||||||
"user": user # Add user to the context
|
"user": request.user # Add user to the context
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/{model_name}/new", response_class=HTMLResponse)
|
@router.get("/{model_name}/new", response_class=HTMLResponse)
|
||||||
|
@require_admin(redirect_url="/auth/login")
|
||||||
async def create_record_form(
|
async def create_record_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
model_name: str,
|
model_name: str,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Show form for creating a new record."""
|
"""Show form for creating a new record."""
|
||||||
# Get user from session for navbar
|
|
||||||
user = None
|
|
||||||
user_id = request.session.get("user_id")
|
|
||||||
if user_id:
|
|
||||||
user = db.query(User).get(user_id)
|
|
||||||
|
|
||||||
# Check admin status
|
|
||||||
if not user or not user.is_admin:
|
|
||||||
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
|
|
||||||
|
|
||||||
if model_name not in MODELS:
|
if model_name not in MODELS:
|
||||||
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
||||||
|
|
||||||
@@ -201,11 +176,12 @@ async def create_record_form(
|
|||||||
"record": None, # No record for new form
|
"record": None, # No record for new form
|
||||||
"foreign_key_options": foreign_key_options,
|
"foreign_key_options": foreign_key_options,
|
||||||
"is_new": True,
|
"is_new": True,
|
||||||
"user": user # Add user to the context
|
"user": request.user # Use request.user from Starlette authentication
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/{model_name}/new")
|
@router.post("/{model_name}/new")
|
||||||
|
@require_admin(redirect_url="/auth/login")
|
||||||
async def create_record(
|
async def create_record(
|
||||||
request: Request,
|
request: Request,
|
||||||
model_name: str,
|
model_name: str,
|
||||||
@@ -254,6 +230,7 @@ async def create_record(
|
|||||||
return RedirectResponse(f"/admin/{model_name}", status_code=303)
|
return RedirectResponse(f"/admin/{model_name}", status_code=303)
|
||||||
|
|
||||||
@router.get("/{model_name}/{record_id}", response_class=HTMLResponse)
|
@router.get("/{model_name}/{record_id}", response_class=HTMLResponse)
|
||||||
|
@require_admin(redirect_url="/auth/login")
|
||||||
async def edit_record_form(
|
async def edit_record_form(
|
||||||
request: Request,
|
request: Request,
|
||||||
model_name: str,
|
model_name: str,
|
||||||
@@ -261,16 +238,6 @@ async def edit_record_form(
|
|||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Show form for editing an existing record."""
|
"""Show form for editing an existing record."""
|
||||||
# Get user from session for navbar
|
|
||||||
user = None
|
|
||||||
user_id = request.session.get("user_id")
|
|
||||||
if user_id:
|
|
||||||
user = db.query(User).get(user_id)
|
|
||||||
|
|
||||||
# Check admin status
|
|
||||||
if not user or not user.is_admin:
|
|
||||||
raise HTTPException(status_code=403, detail="Forbidden: Admin access required")
|
|
||||||
|
|
||||||
if model_name not in MODELS:
|
if model_name not in MODELS:
|
||||||
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
||||||
|
|
||||||
@@ -311,11 +278,12 @@ async def edit_record_form(
|
|||||||
"record": record_data,
|
"record": record_data,
|
||||||
"foreign_key_options": foreign_key_options,
|
"foreign_key_options": foreign_key_options,
|
||||||
"is_new": False,
|
"is_new": False,
|
||||||
"user": user # Add user to the context
|
"user": request.user # Use request.user from Starlette authentication
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/{model_name}/{record_id}")
|
@router.post("/{model_name}/{record_id}")
|
||||||
|
@require_admin(redirect_url="/auth/login")
|
||||||
async def update_record(
|
async def update_record(
|
||||||
request: Request,
|
request: Request,
|
||||||
model_name: str,
|
model_name: str,
|
||||||
@@ -364,6 +332,7 @@ async def update_record(
|
|||||||
return RedirectResponse(f"/admin/{model_name}", status_code=303)
|
return RedirectResponse(f"/admin/{model_name}", status_code=303)
|
||||||
|
|
||||||
@router.get("/{model_name}/{record_id}/delete")
|
@router.get("/{model_name}/{record_id}/delete")
|
||||||
|
@require_admin(redirect_url="/auth/login")
|
||||||
async def delete_record(
|
async def delete_record(
|
||||||
request: Request,
|
request: Request,
|
||||||
model_name: str,
|
model_name: str,
|
||||||
|
|||||||
+114
-36
@@ -1,7 +1,7 @@
|
|||||||
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks
|
from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from typing import Optional
|
from typing import Optional, List, Dict, Any
|
||||||
import secrets
|
import secrets
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
@@ -12,7 +12,7 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from ..db import get_db
|
from ..db import get_db
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from ..auth.oauth import authentik_oauth
|
from ..auth.oauth import oauth_manager
|
||||||
from ..templates_config import templates
|
from ..templates_config import templates
|
||||||
from ..security import verify_password, get_password_hash
|
from ..security import verify_password, get_password_hash
|
||||||
from ..utils.mail import send_password_reset_email
|
from ..utils.mail import send_password_reset_email
|
||||||
@@ -31,11 +31,18 @@ async def login_page(request: Request, error: Optional[str] = None, message: Opt
|
|||||||
if not message:
|
if not message:
|
||||||
message = "You are already logged in."
|
message = "You are already logged in."
|
||||||
|
|
||||||
|
# Get available OAuth providers for the login screen
|
||||||
|
oauth_providers = oauth_manager.get_available_providers()
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"auth/login.html",
|
"auth/login.html",
|
||||||
{"request": request, "error": error, "message": message,
|
{
|
||||||
"show_oauth": True, "oauth_provider_name": "Authentik",
|
"request": request,
|
||||||
"user": user} # Add user to context
|
"error": error,
|
||||||
|
"message": message,
|
||||||
|
"user": user,
|
||||||
|
"oauth_providers": oauth_providers
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/login", response_class=HTMLResponse)
|
@router.post("/login", response_class=HTMLResponse)
|
||||||
@@ -71,8 +78,7 @@ async def login_post(
|
|||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"error": "Please verify your email address before logging in",
|
"error": "Please verify your email address before logging in",
|
||||||
"show_oauth": True,
|
"oauth_providers": oauth_manager.get_available_providers(),
|
||||||
"oauth_provider_name": "Authentik",
|
|
||||||
"unverified_user_id": user.id,
|
"unverified_user_id": user.id,
|
||||||
"unverified_email": user.email
|
"unverified_email": user.email
|
||||||
}
|
}
|
||||||
@@ -85,8 +91,7 @@ async def login_post(
|
|||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"error": error,
|
"error": error,
|
||||||
"show_oauth": True,
|
"oauth_providers": oauth_manager.get_available_providers()
|
||||||
"oauth_provider_name": "Authentik"
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -317,21 +322,21 @@ async def resend_verification(
|
|||||||
status_code=HTTP_303_SEE_OTHER
|
status_code=HTTP_303_SEE_OTHER
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/oauth-login")
|
@router.get("/oauth-login/{provider_id}")
|
||||||
async def oauth_login(request: Request):
|
async def oauth_login(request: Request, provider_id: str):
|
||||||
"""Start the OAuth login flow"""
|
"""Start the OAuth login flow for the specified provider"""
|
||||||
# Generate the redirect URI
|
# Generate the redirect URI
|
||||||
base_url = str(request.base_url)
|
base_url = str(request.base_url)
|
||||||
redirect_uri = f"{base_url}auth/oauth-callback"
|
redirect_uri = f"{base_url}auth/oauth-callback/{provider_id}"
|
||||||
|
|
||||||
# Request a login URL from the Authentik provider
|
# Request a login URL from the provider
|
||||||
try:
|
try:
|
||||||
# Set a session ID to validate the callback
|
# Set a session ID to validate the callback
|
||||||
if "session_id" not in request.session:
|
if "session_id" not in request.session:
|
||||||
request.session["session_id"] = str(uuid.uuid4())
|
request.session["session_id"] = str(uuid.uuid4())
|
||||||
|
|
||||||
# Get the authorization URL - make sure to await it
|
# Get the authorization URL
|
||||||
auth_url = await authentik_oauth.get_login_url(request, redirect_uri)
|
auth_url = await oauth_manager.get_login_url(request, provider_id, redirect_uri)
|
||||||
|
|
||||||
# Redirect to the authorization URL
|
# Redirect to the authorization URL
|
||||||
return RedirectResponse(auth_url)
|
return RedirectResponse(auth_url)
|
||||||
@@ -342,9 +347,16 @@ async def oauth_login(request: Request):
|
|||||||
status_code=HTTP_303_SEE_OTHER
|
status_code=HTTP_303_SEE_OTHER
|
||||||
)
|
)
|
||||||
|
|
||||||
@router.get("/oauth-callback")
|
@router.get("/oauth-callback/{provider_id}")
|
||||||
async def oauth_callback(request: Request, code: Optional[str] = None, state: Optional[str] = None, error: Optional[str] = None, db: Session = Depends(get_db)):
|
async def oauth_callback(
|
||||||
"""Handle the OAuth callback"""
|
request: Request,
|
||||||
|
provider_id: str,
|
||||||
|
code: Optional[str] = None,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Handle the OAuth callback for the specified provider"""
|
||||||
if error:
|
if error:
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
f"/auth/login?error=OAuth+login+failed:+{error}",
|
f"/auth/login?error=OAuth+login+failed:+{error}",
|
||||||
@@ -359,11 +371,11 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
|
|||||||
|
|
||||||
# Generate the redirect URI that matches the one used in the initial request
|
# Generate the redirect URI that matches the one used in the initial request
|
||||||
base_url = str(request.base_url)
|
base_url = str(request.base_url)
|
||||||
redirect_uri = f"{base_url}auth/oauth-callback"
|
redirect_uri = f"{base_url}auth/oauth-callback/{provider_id}"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Get user info from the provider
|
# Get normalized user info from the provider
|
||||||
user_info = await authentik_oauth.get_user_info(request, redirect_uri, code)
|
user_info = await oauth_manager.get_user_info(request, provider_id, redirect_uri, code)
|
||||||
|
|
||||||
if not user_info:
|
if not user_info:
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
@@ -371,24 +383,43 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
|
|||||||
status_code=HTTP_303_SEE_OTHER
|
status_code=HTTP_303_SEE_OTHER
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract user details from OAuth info
|
# Extract user details from the normalized OAuth info
|
||||||
sub = user_info.get("sub", "")
|
provider_user_id = user_info.get("id")
|
||||||
email = user_info.get("email", "")
|
email = user_info.get("email")
|
||||||
name = user_info.get("preferred_username", "") or user_info.get("name", "") or email.split("@")[0]
|
name = user_info.get("name") or (email.split("@")[0] if email else f"user_{provider_id}")
|
||||||
picture = user_info.get("picture", None)
|
first_name = user_info.get("first_name")
|
||||||
|
last_name = user_info.get("last_name")
|
||||||
|
picture = user_info.get("picture")
|
||||||
|
|
||||||
# Check if the user already exists
|
if not email:
|
||||||
|
return RedirectResponse(
|
||||||
|
"/auth/login?error=Email+address+not+provided+by+the+OAuth+provider",
|
||||||
|
status_code=HTTP_303_SEE_OTHER
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if the user already exists with this email
|
||||||
user = db.query(User).filter(User.email == email).first()
|
user = db.query(User).filter(User.email == email).first()
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
print(f"Creating new user with email {email} and username {name}")
|
# Set username to be unique if it already exists
|
||||||
|
username = name
|
||||||
|
base_username = username
|
||||||
|
counter = 1
|
||||||
|
|
||||||
|
# Check if username already exists
|
||||||
|
while db.query(User).filter(User.username == username).first():
|
||||||
|
username = f"{base_username}{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
# Create a new user
|
# Create a new user
|
||||||
user = User(
|
user = User(
|
||||||
username=name,
|
username=username,
|
||||||
email=email,
|
email=email,
|
||||||
oauth_id=sub,
|
oauth_id=provider_user_id,
|
||||||
is_oauth_user=True,
|
is_oauth_user=True,
|
||||||
oauth_provider="authentik",
|
oauth_provider=provider_id,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
picture=picture,
|
picture=picture,
|
||||||
is_verified=True # OAuth users are considered verified
|
is_verified=True # OAuth users are considered verified
|
||||||
)
|
)
|
||||||
@@ -399,13 +430,27 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
|
|||||||
# Update existing user's OAuth information
|
# Update existing user's OAuth information
|
||||||
if not user.is_oauth_user:
|
if not user.is_oauth_user:
|
||||||
user.is_oauth_user = True
|
user.is_oauth_user = True
|
||||||
user.oauth_id = sub
|
user.oauth_id = provider_user_id
|
||||||
user.oauth_provider = "authentik"
|
|
||||||
|
|
||||||
# Update profile picture if available
|
# If user has a different OAuth provider, add this one as additional
|
||||||
|
if user.oauth_provider and user.oauth_provider != provider_id:
|
||||||
|
if not user.additional_oauth_providers:
|
||||||
|
user.additional_oauth_providers = {}
|
||||||
|
user.additional_oauth_providers[provider_id] = provider_user_id
|
||||||
|
else:
|
||||||
|
user.oauth_provider = provider_id
|
||||||
|
|
||||||
|
# Update profile info if not already set
|
||||||
|
if first_name and not user.first_name:
|
||||||
|
user.first_name = first_name
|
||||||
|
if last_name and not user.last_name:
|
||||||
|
user.last_name = last_name
|
||||||
if picture and not user.picture:
|
if picture and not user.picture:
|
||||||
user.picture = picture
|
user.picture = picture
|
||||||
|
|
||||||
|
# Update last login time
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Set session data
|
# Set session data
|
||||||
@@ -413,8 +458,11 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
|
|||||||
request.session["username"] = user.username
|
request.session["username"] = user.username
|
||||||
request.session["is_authenticated"] = True
|
request.session["is_authenticated"] = True
|
||||||
request.session["is_admin"] = user.is_admin
|
request.session["is_admin"] = user.is_admin
|
||||||
|
request.session["oauth_provider"] = provider_id # Store the provider for possible UI customization
|
||||||
|
|
||||||
return RedirectResponse("/dashboard", status_code=HTTP_303_SEE_OTHER)
|
# Redirect to dashboard or the requested next page
|
||||||
|
next_page = request.query_params.get("next", "/dashboard")
|
||||||
|
return RedirectResponse(next_page, status_code=HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"OAuth callback error: {str(e)}")
|
print(f"OAuth callback error: {str(e)}")
|
||||||
@@ -423,6 +471,36 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op
|
|||||||
status_code=HTTP_303_SEE_OTHER
|
status_code=HTTP_303_SEE_OTHER
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Legacy route for backward compatibility
|
||||||
|
@router.get("/oauth-login")
|
||||||
|
async def legacy_oauth_login(request: Request):
|
||||||
|
"""Redirect to Authentik OAuth login for backward compatibility"""
|
||||||
|
return RedirectResponse("/auth/oauth-login/authentik", status_code=HTTP_302_FOUND)
|
||||||
|
|
||||||
|
@router.get("/oauth-callback")
|
||||||
|
async def legacy_oauth_callback(
|
||||||
|
request: Request,
|
||||||
|
code: Optional[str] = None,
|
||||||
|
state: Optional[str] = None,
|
||||||
|
error: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Redirect to Authentik OAuth callback handler for backward compatibility"""
|
||||||
|
params = []
|
||||||
|
if code:
|
||||||
|
params.append(f"code={code}")
|
||||||
|
if state:
|
||||||
|
params.append(f"state={state}")
|
||||||
|
if error:
|
||||||
|
params.append(f"error={error}")
|
||||||
|
|
||||||
|
query_string = "&".join(params)
|
||||||
|
redirect_url = f"/auth/oauth-callback/authentik"
|
||||||
|
if query_string:
|
||||||
|
redirect_url = f"{redirect_url}?{query_string}"
|
||||||
|
|
||||||
|
return RedirectResponse(redirect_url, status_code=HTTP_302_FOUND)
|
||||||
|
|
||||||
@router.get("/logout")
|
@router.get("/logout")
|
||||||
async def logout(request: Request):
|
async def logout(request: Request):
|
||||||
"""Log out the user"""
|
"""Log out the user"""
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from ..schemas import TeamCreate
|
|||||||
from ..templates_config import templates
|
from ..templates_config import templates
|
||||||
from ..utils.auth import get_current_user, is_team_captain
|
from ..utils.auth import get_current_user, is_team_captain
|
||||||
from ..utils.mail import send_team_join_request_notification, send_join_request_response
|
from ..utils.mail import send_team_join_request_notification, send_join_request_response
|
||||||
|
from ..auth.permissions import require_auth, require_team_captain
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -605,3 +606,61 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
|
|||||||
"is_open": is_open # Pass is_open to the template
|
"is_open": is_open # Pass is_open to the template
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@router.get("/{team_id}/manage", response_class=HTMLResponse)
|
||||||
|
@require_team_captain()
|
||||||
|
async def manage_team(request: Request, team_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Team management page for captains.
|
||||||
|
This route is protected by the require_team_captain decorator which
|
||||||
|
automatically checks if the user is a captain of the team.
|
||||||
|
"""
|
||||||
|
# Get team
|
||||||
|
team = db.query(Team).filter_by(id=team_id).first()
|
||||||
|
if not team:
|
||||||
|
raise HTTPException(status_code=404, detail="Team not found")
|
||||||
|
|
||||||
|
# Get pending join requests
|
||||||
|
join_requests = db.query(TeamJoinRequest).filter(
|
||||||
|
TeamJoinRequest.team_id == team_id,
|
||||||
|
TeamJoinRequest.status == "pending"
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Get requesters' info
|
||||||
|
pending_requests = []
|
||||||
|
for req in join_requests:
|
||||||
|
requester = db.query(User).filter(User.id == req.user_id).first()
|
||||||
|
if requester:
|
||||||
|
pending_requests.append({
|
||||||
|
"request": req,
|
||||||
|
"user": requester
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get team members
|
||||||
|
memberships = db.query(TeamMembership).filter_by(team_id=team_id).all()
|
||||||
|
team_members = []
|
||||||
|
|
||||||
|
for membership in memberships:
|
||||||
|
member = db.query(User).filter_by(id=membership.user_id).first()
|
||||||
|
if member:
|
||||||
|
team_members.append({
|
||||||
|
"user": member,
|
||||||
|
"is_admin": membership.is_admin,
|
||||||
|
"is_captain": membership.is_captain
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get current user from session for template
|
||||||
|
current_user = None
|
||||||
|
if hasattr(request, "session") and request.session.get("user_id"):
|
||||||
|
current_user = db.query(User).filter_by(id=request.session.get("user_id")).first()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"teams/manage.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"team": team,
|
||||||
|
"pending_requests": pending_requests,
|
||||||
|
"team_members": team_members,
|
||||||
|
"user": current_user # Use current user from session instead of request.user
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
+36
-2
@@ -28,15 +28,18 @@ services:
|
|||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
|
# Database configuration
|
||||||
DB_HOST: "db"
|
DB_HOST: "db"
|
||||||
DB_PORT: "3306"
|
DB_PORT: "3306"
|
||||||
DB_NAME: "pubquiz_db"
|
DB_NAME: "pubquiz_db"
|
||||||
DB_USER: "pubquiz_user"
|
DB_USER: "pubquiz_user"
|
||||||
DB_PASS: "pubquiz_pass"
|
DB_PASS: "pubquiz_pass"
|
||||||
PYTHONUNBUFFERED: "1"
|
PYTHONUNBUFFERED: "1"
|
||||||
# Use environment variable from .env instead of hard-coding
|
|
||||||
|
# Base URL
|
||||||
LEAGUELEDGER_BASE_URL: ${LEAGUELEDGER_BASE_URL:-http://localhost:8000}
|
LEAGUELEDGER_BASE_URL: ${LEAGUELEDGER_BASE_URL:-http://localhost:8000}
|
||||||
# Email configuration for Mailhog
|
|
||||||
|
# Email configuration for Mailpit
|
||||||
MAIL_USERNAME: ""
|
MAIL_USERNAME: ""
|
||||||
MAIL_PASSWORD: ""
|
MAIL_PASSWORD: ""
|
||||||
MAIL_FROM: "noreply@leagueledger.net"
|
MAIL_FROM: "noreply@leagueledger.net"
|
||||||
@@ -47,6 +50,37 @@ services:
|
|||||||
MAIL_SSL_TLS: "False"
|
MAIL_SSL_TLS: "False"
|
||||||
MAIL_USE_CREDENTIALS: "False"
|
MAIL_USE_CREDENTIALS: "False"
|
||||||
MAIL_VALIDATE_CERTS: "False"
|
MAIL_VALIDATE_CERTS: "False"
|
||||||
|
|
||||||
|
# OAuth configuration - read from .env file
|
||||||
|
# Authentik
|
||||||
|
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-}
|
||||||
|
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-}
|
||||||
|
AUTHENTIK_CONFIG_URL: ${AUTHENTIK_CONFIG_URL:-}
|
||||||
|
|
||||||
|
# Google OAuth
|
||||||
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
|
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
|
||||||
|
|
||||||
|
# GitHub OAuth
|
||||||
|
GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-}
|
||||||
|
GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-}
|
||||||
|
|
||||||
|
# Facebook OAuth
|
||||||
|
FACEBOOK_CLIENT_ID: ${FACEBOOK_CLIENT_ID:-}
|
||||||
|
FACEBOOK_CLIENT_SECRET: ${FACEBOOK_CLIENT_SECRET:-}
|
||||||
|
|
||||||
|
# Microsoft OAuth
|
||||||
|
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
|
||||||
|
MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-}
|
||||||
|
MICROSOFT_TENANT: ${MICROSOFT_TENANT:-common}
|
||||||
|
|
||||||
|
# Discord OAuth
|
||||||
|
DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:-}
|
||||||
|
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:-}
|
||||||
|
|
||||||
|
# LinkedIn OAuth
|
||||||
|
LINKEDIN_CLIENT_ID: ${LINKEDIN_CLIENT_ID:-}
|
||||||
|
LINKEDIN_CLIENT_SECRET: ${LINKEDIN_CLIENT_SECRET:-}
|
||||||
command: uvicorn app.main:app --host 0.0.0.0 --reload
|
command: uvicorn app.main:app --host 0.0.0.0 --reload
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|||||||
@@ -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
|
# PDF generation for QR code sheets
|
||||||
reportlab>=3.6.12
|
reportlab>=3.6.12
|
||||||
babel>=2.12.1
|
babel>=2.12.1
|
||||||
|
|
||||||
|
# JSON handling for additional OAuth providers storage
|
||||||
|
simplejson>=3.19.2
|
||||||
|
|||||||
Reference in New Issue
Block a user