diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..1d13b43 --- /dev/null +++ b/.readthedocs.yaml @@ -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 \ No newline at end of file diff --git a/app/auth/middleware.py b/app/auth/middleware.py new file mode 100644 index 0000000..a38ebd6 --- /dev/null +++ b/app/auth/middleware.py @@ -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) \ No newline at end of file diff --git a/app/auth/oauth.py b/app/auth/oauth.py index 879adb9..5b6f3fa 100644 --- a/app/auth/oauth.py +++ b/app/auth/oauth.py @@ -1,22 +1,119 @@ import os +from abc import ABC, abstractmethod +from httpx_oauth.clients.google import GoogleOAuth2 +from httpx_oauth.clients.github import GitHubOAuth2 +from httpx_oauth.clients.facebook import FacebookOAuth2 +from httpx_oauth.clients.discord import DiscordOAuth2 +# LinkedIn OAuth client +from httpx_oauth.clients.linkedin import LinkedInOAuth2 +# Microsoft client import may not be available in all httpx_oauth versions +try: + from httpx_oauth.clients.microsoft import MicrosoftOAuth2 + MICROSOFT_AVAILABLE = True +except ImportError: + # Custom implementation if the package doesn't have it + from httpx_oauth.oauth2 import OAuth2, GetAccessTokenError + MICROSOFT_AVAILABLE = False + + # Basic Microsoft OAuth2 implementation if not available in the library + class MicrosoftOAuth2(OAuth2): + def __init__( + self, + client_id: str, + client_secret: str, + tenant: str = "common", + ): + super().__init__( + client_id=client_id, + client_secret=client_secret, + authorize_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize", + access_token_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", + refresh_token_endpoint=f"https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", + base_scopes=["openid", "profile", "email"], + ) + from httpx_oauth.clients.openid import OpenID from httpx_oauth.oauth2 import GetAccessTokenError from fastapi import HTTPException, Request from starlette.responses import RedirectResponse -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, List, Type import json import httpx from urllib.parse import urlencode -class AuthentikOAuth: +class OAuthProvider(ABC): + """Base class for all OAuth providers""" + + # Provider identifier - should be unique and lowercase + provider_id = "base" + + # Display name for UI + display_name = "Base Provider" + + # Icon class (for UI rendering, e.g. Font Awesome) + icon_class = "fas fa-sign-in-alt" + + # Default button color (hex or valid CSS color name) + button_color = "#333333" + def __init__(self): - self.client_id = os.getenv("AUTHENTIK_CLIENT_ID", "dRXLBdTdG6JSHqkcM0ZQBPwBVMBrG6SF32LZ1XAT") - self.client_secret = os.getenv("AUTHENTIK_CLIENT_SECRET", - "hn1aKecLeYj1tVc7QtsavrWjSOF4t7Ty1akVTmUqvIFJF1y0H3Myv7InUxAX6E2GLpMxxhhZZ2aUSJ9VEQz7zGcMbgUeMStxx2U7bEQxmuOGjZf0XJbOBGjdwGZYJlz7") - self.config_url = os.getenv("AUTHENTIK_CONFIG_URL", - "https://authentik.hosterra.net/application/o/leagueledger/.well-known/openid-configuration") self.client = None self.initialize_client() + + @abstractmethod + def initialize_client(self): + """Initialize the specific OAuth client""" + pass + + @abstractmethod + async def get_login_url(self, request: Request, redirect_uri: str) -> str: + """Get the authorization URL for this provider""" + pass + + @abstractmethod + async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]: + """Get user information from the provider""" + pass + + def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]: + """ + Normalize provider-specific user data into a standard format + + Returns: + Dict with standard fields: + - id: Unique identifier from provider + - email: User email (if available) + - name: User's full/display name + - first_name: User's first name (if available) + - last_name: User's last name (if available) + - picture: URL to user's avatar/picture (if available) + - raw: The original user_info dict + """ + # Default implementation - should be overridden by providers + return { + "id": str(user_info.get("id", "")), + "email": user_info.get("email"), + "name": user_info.get("name"), + "first_name": user_info.get("given_name"), + "last_name": user_info.get("family_name"), + "picture": user_info.get("picture"), + "raw": user_info + } + +class AuthentikOAuth(OAuthProvider): + """Authentik OpenID Connect OAuth provider""" + + provider_id = "authentik" + display_name = "Authentik" + icon_class = "fas fa-shield-alt" + button_color = "#fd4b2d" + + def __init__(self): + self.client_id = os.getenv("AUTHENTIK_CLIENT_ID", "yourAuthentikClientID") + self.client_secret = os.getenv("AUTHENTIK_CLIENT_SECRET", "yourAuthentikClientSecret") + self.config_url = os.getenv("AUTHENTIK_CONFIG_URL", + "https://authentik.example.com/application/o/leagueledger/.well-known/openid-configuration") + super().__init__() def initialize_client(self): try: @@ -94,5 +191,701 @@ class AuthentikOAuth: print(f"Error getting user info: {str(e)}") raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") -# Instantiate the OAuth client for the application to use -authentik_oauth = AuthentikOAuth() +class GoogleOAuth(OAuthProvider): + """Google OAuth provider implementation""" + + provider_id = "google" + display_name = "Google" + icon_class = "fab fa-google" + button_color = "#4285F4" + + def __init__(self): + self.client_id = os.getenv("GOOGLE_CLIENT_ID", "") + self.client_secret = os.getenv("GOOGLE_CLIENT_SECRET", "") + super().__init__() + + def initialize_client(self): + if not self.client_id or not self.client_secret: + self.client = None + return + + try: + self.client = GoogleOAuth2( + client_id=self.client_id, + client_secret=self.client_secret + ) + except Exception as e: + print(f"Error initializing Google OAuth client: {str(e)}") + self.client = None + + async def get_login_url(self, request: Request, redirect_uri: str) -> str: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="Google OAuth client could not be initialized") + + try: + authorization_url = await self.client.get_authorization_url( + redirect_uri=redirect_uri, + scope=["openid", "email", "profile"], + state=str(request.session.get("session_id", "")), + extras_params={"access_type": "offline", "prompt": "select_account"} + ) + return authorization_url + except Exception as e: + print(f"Error getting Google authorization URL: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="Google OAuth client could not be initialized") + + try: + # Exchange code for token + token = await self.client.get_access_token( + code=code, + redirect_uri=redirect_uri + ) + + access_token = token.get("access_token") + if not access_token: + raise HTTPException(status_code=400, detail="Could not get Google access token") + + # Get user info from Google + async with httpx.AsyncClient() as client: + headers = {"Authorization": f"Bearer {access_token}"} + response = await client.get( + "https://www.googleapis.com/oauth2/v3/userinfo", + headers=headers + ) + + if response.status_code != 200: + raise HTTPException(status_code=500, detail=f"Error fetching Google user info: {response.text}") + + return response.json() + + except GetAccessTokenError as e: + error_description = e.args[0] + raise HTTPException(status_code=400, detail=f"Google OAuth error: {error_description}") + except Exception as e: + print(f"Error getting Google user info: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]: + # Google specific normalization + return { + "id": user_info.get("sub", ""), + "email": user_info.get("email"), + "name": user_info.get("name"), + "first_name": user_info.get("given_name"), + "last_name": user_info.get("family_name"), + "picture": user_info.get("picture"), + "raw": user_info + } + +class GitHubOAuth(OAuthProvider): + """GitHub OAuth provider implementation""" + + provider_id = "github" + display_name = "GitHub" + icon_class = "fab fa-github" + button_color = "#171515" + + def __init__(self): + self.client_id = os.getenv("GITHUB_CLIENT_ID", "") + self.client_secret = os.getenv("GITHUB_CLIENT_SECRET", "") + super().__init__() + + def initialize_client(self): + if not self.client_id or not self.client_secret: + self.client = None + return + + try: + self.client = GitHubOAuth2( + client_id=self.client_id, + client_secret=self.client_secret + ) + except Exception as e: + print(f"Error initializing GitHub OAuth client: {str(e)}") + self.client = None + + async def get_login_url(self, request: Request, redirect_uri: str) -> str: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="GitHub OAuth client could not be initialized") + + try: + authorization_url = await self.client.get_authorization_url( + redirect_uri=redirect_uri, + scope=["user:email"], + state=str(request.session.get("session_id", "")), + ) + return authorization_url + except Exception as e: + print(f"Error getting GitHub authorization URL: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="GitHub OAuth client could not be initialized") + + try: + # Exchange code for token + token = await self.client.get_access_token( + code=code, + redirect_uri=redirect_uri + ) + + access_token = token.get("access_token") + if not access_token: + raise HTTPException(status_code=400, detail="Could not get GitHub access token") + + # Get user info from GitHub + user_data = {} + async with httpx.AsyncClient() as client: + headers = { + "Authorization": f"token {access_token}", + "Accept": "application/vnd.github.v3+json" + } + + # Get user profile + user_response = await client.get( + "https://api.github.com/user", + headers=headers + ) + + if user_response.status_code != 200: + raise HTTPException(status_code=500, detail=f"Error fetching GitHub user info: {user_response.text}") + + user_data = user_response.json() + + # Get user emails + email_response = await client.get( + "https://api.github.com/user/emails", + headers=headers + ) + + if email_response.status_code == 200: + emails = email_response.json() + primary_email = next((email for email in emails if email.get("primary") is True), None) + if primary_email: + user_data["email"] = primary_email.get("email") + + return user_data + + except GetAccessTokenError as e: + error_description = e.args[0] + raise HTTPException(status_code=400, detail=f"GitHub OAuth error: {error_description}") + except Exception as e: + print(f"Error getting GitHub user info: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]: + # GitHub specific normalization + name_parts = (user_info.get("name") or "").split(" ", 1) + first_name = name_parts[0] if name_parts else "" + last_name = name_parts[1] if len(name_parts) > 1 else "" + + return { + "id": str(user_info.get("id", "")), + "email": user_info.get("email"), + "name": user_info.get("name") or user_info.get("login"), + "first_name": first_name, + "last_name": last_name, + "picture": user_info.get("avatar_url"), + "raw": user_info + } + +class FacebookOAuth(OAuthProvider): + """Facebook OAuth provider implementation""" + + provider_id = "facebook" + display_name = "Facebook" + icon_class = "fab fa-facebook" + button_color = "#1877F2" + + def __init__(self): + self.client_id = os.getenv("FACEBOOK_CLIENT_ID", "") + self.client_secret = os.getenv("FACEBOOK_CLIENT_SECRET", "") + super().__init__() + + def initialize_client(self): + if not self.client_id or not self.client_secret: + self.client = None + return + + try: + self.client = FacebookOAuth2( + client_id=self.client_id, + client_secret=self.client_secret + ) + except Exception as e: + print(f"Error initializing Facebook OAuth client: {str(e)}") + self.client = None + + async def get_login_url(self, request: Request, redirect_uri: str) -> str: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="Facebook OAuth client could not be initialized") + + try: + authorization_url = await self.client.get_authorization_url( + redirect_uri=redirect_uri, + scope=["email", "public_profile"], + state=str(request.session.get("session_id", "")) + ) + return authorization_url + except Exception as e: + print(f"Error getting Facebook authorization URL: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="Facebook OAuth client could not be initialized") + + try: + # Exchange code for token + token = await self.client.get_access_token( + code=code, + redirect_uri=redirect_uri + ) + + access_token = token.get("access_token") + if not access_token: + raise HTTPException(status_code=400, detail="Could not get Facebook access token") + + # Get user info from Facebook + async with httpx.AsyncClient() as client: + response = await client.get( + "https://graph.facebook.com/me", + params={ + "access_token": access_token, + "fields": "id,name,email,first_name,last_name,picture" + } + ) + + if response.status_code != 200: + raise HTTPException(status_code=500, detail=f"Error fetching Facebook user info: {response.text}") + + return response.json() + + except GetAccessTokenError as e: + error_description = e.args[0] + raise HTTPException(status_code=400, detail=f"Facebook OAuth error: {error_description}") + except Exception as e: + print(f"Error getting Facebook user info: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]: + # Facebook specific normalization + picture_url = None + if "picture" in user_info and "data" in user_info["picture"]: + picture_url = user_info["picture"]["data"].get("url") + + return { + "id": user_info.get("id", ""), + "email": user_info.get("email"), + "name": user_info.get("name"), + "first_name": user_info.get("first_name"), + "last_name": user_info.get("last_name"), + "picture": picture_url, + "raw": user_info + } + +class MicrosoftOAuth(OAuthProvider): + """Microsoft OAuth provider implementation""" + + provider_id = "microsoft" + display_name = "Microsoft" + icon_class = "fab fa-microsoft" + button_color = "#00A4EF" + + def __init__(self): + self.client_id = os.getenv("MICROSOFT_CLIENT_ID", "") + self.client_secret = os.getenv("MICROSOFT_CLIENT_SECRET", "") + self.tenant = os.getenv("MICROSOFT_TENANT", "common") # "common" for multi-tenant apps + super().__init__() + + def initialize_client(self): + if not self.client_id or not self.client_secret: + self.client = None + return + + try: + self.client = MicrosoftOAuth2( + client_id=self.client_id, + client_secret=self.client_secret, + tenant=self.tenant + ) + except Exception as e: + print(f"Error initializing Microsoft OAuth client: {str(e)}") + self.client = None + + async def get_login_url(self, request: Request, redirect_uri: str) -> str: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="Microsoft OAuth client could not be initialized") + + try: + authorization_url = await self.client.get_authorization_url( + redirect_uri=redirect_uri, + scope=["User.Read", "email", "profile", "openid"], + state=str(request.session.get("session_id", "")) + ) + return authorization_url + except Exception as e: + print(f"Error getting Microsoft authorization URL: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="Microsoft OAuth client could not be initialized") + + try: + # Exchange code for token + token = await self.client.get_access_token( + code=code, + redirect_uri=redirect_uri + ) + + access_token = token.get("access_token") + if not access_token: + raise HTTPException(status_code=400, detail="Could not get Microsoft access token") + + # Get user info from Microsoft Graph API + async with httpx.AsyncClient() as client: + headers = {"Authorization": f"Bearer {access_token}"} + response = await client.get( + "https://graph.microsoft.com/v1.0/me", + headers=headers + ) + + if response.status_code != 200: + raise HTTPException(status_code=500, detail=f"Error fetching Microsoft user info: {response.text}") + + return response.json() + + except GetAccessTokenError as e: + error_description = e.args[0] + raise HTTPException(status_code=400, detail=f"Microsoft OAuth error: {error_description}") + except Exception as e: + print(f"Error getting Microsoft user info: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]: + return { + "id": user_info.get("id", ""), + "email": user_info.get("mail") or user_info.get("userPrincipalName"), + "name": user_info.get("displayName"), + "first_name": user_info.get("givenName"), + "last_name": user_info.get("surname"), + "picture": None, # Microsoft Graph doesn't include photo in basic profile + "raw": user_info + } + +class DiscordOAuth(OAuthProvider): + """Discord OAuth provider implementation""" + + provider_id = "discord" + display_name = "Discord" + icon_class = "fab fa-discord" + button_color = "#5865F2" + + def __init__(self): + self.client_id = os.getenv("DISCORD_CLIENT_ID", "") + self.client_secret = os.getenv("DISCORD_CLIENT_SECRET", "") + super().__init__() + + def initialize_client(self): + if not self.client_id or not self.client_secret: + self.client = None + return + + try: + self.client = DiscordOAuth2( + client_id=self.client_id, + client_secret=self.client_secret + ) + except Exception as e: + print(f"Error initializing Discord OAuth client: {str(e)}") + self.client = None + + async def get_login_url(self, request: Request, redirect_uri: str) -> str: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="Discord OAuth client could not be initialized") + + try: + authorization_url = await self.client.get_authorization_url( + redirect_uri=redirect_uri, + scope=["identify", "email"], + state=str(request.session.get("session_id", "")) + ) + return authorization_url + except Exception as e: + print(f"Error getting Discord authorization URL: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="Discord OAuth client could not be initialized") + + try: + # Exchange code for token + token = await self.client.get_access_token( + code=code, + redirect_uri=redirect_uri + ) + + access_token = token.get("access_token") + if not access_token: + raise HTTPException(status_code=400, detail="Could not get Discord access token") + + # Get user info from Discord API + async with httpx.AsyncClient() as client: + headers = {"Authorization": f"Bearer {access_token}"} + response = await client.get( + "https://discord.com/api/users/@me", + headers=headers + ) + + if response.status_code != 200: + raise HTTPException(status_code=500, detail=f"Error fetching Discord user info: {response.text}") + + return response.json() + + except GetAccessTokenError as e: + error_description = e.args[0] + raise HTTPException(status_code=400, detail=f"Discord OAuth error: {error_description}") + except Exception as e: + print(f"Error getting Discord user info: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]: + avatar_url = None + if user_info.get("avatar"): + user_id = user_info.get("id") + avatar_hash = user_info.get("avatar") + avatar_url = f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.png" + + # Discord doesn't split names, just has a username + return { + "id": user_info.get("id", ""), + "email": user_info.get("email"), + "name": user_info.get("username") or user_info.get("global_name"), + "first_name": user_info.get("username", "").split("#", 1)[0], + "last_name": "", + "picture": avatar_url, + "raw": user_info + } + +class LinkedInOAuth(OAuthProvider): + """LinkedIn OAuth provider implementation using OpenID Connect""" + + provider_id = "linkedin" + display_name = "LinkedIn" + icon_class = "fab fa-linkedin" + button_color = "#0077B5" + + # LinkedIn OIDC endpoints + AUTHORIZATION_URL = "https://www.linkedin.com/oauth/v2/authorization" + TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken" + USERINFO_URL = "https://api.linkedin.com/v2/userinfo" + + def __init__(self): + self.client_id = os.getenv("LINKEDIN_CLIENT_ID", "") + self.client_secret = os.getenv("LINKEDIN_CLIENT_SECRET", "") + super().__init__() + + def initialize_client(self): + if not self.client_id or not self.client_secret: + self.client = None + return + + try: + # Create a custom OAuth2 client for LinkedIn OpenID Connect + from httpx_oauth.oauth2 import OAuth2 + self.client = OAuth2( + client_id=self.client_id, + client_secret=self.client_secret, + authorize_endpoint=self.AUTHORIZATION_URL, + access_token_endpoint=self.TOKEN_URL, + refresh_token_endpoint=self.TOKEN_URL, + base_scopes=["openid", "profile", "email"] + ) + except Exception as e: + print(f"Error initializing LinkedIn OAuth client: {str(e)}") + self.client = None + + async def get_login_url(self, request: Request, redirect_uri: str) -> str: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="LinkedIn OAuth client could not be initialized") + + try: + authorization_url = await self.client.get_authorization_url( + redirect_uri=redirect_uri, + # Using the required OpenID Connect scopes + scope=["openid", "profile", "email"], + state=str(request.session.get("session_id", "")) + ) + return authorization_url + except Exception as e: + print(f"Error getting LinkedIn authorization URL: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + async def get_user_info(self, request: Request, redirect_uri: str, code: str) -> Dict[str, Any]: + if not self.client: + self.initialize_client() + + if not self.client: + raise HTTPException(status_code=500, detail="LinkedIn OAuth client could not be initialized") + + try: + # Exchange code for token + token = await self.client.get_access_token( + code=code, + redirect_uri=redirect_uri + ) + + access_token = token.get("access_token") + id_token = token.get("id_token") # JWT token containing basic user info + + if not access_token: + raise HTTPException(status_code=400, detail="Could not get LinkedIn access token") + + # Get user info from LinkedIn UserInfo endpoint (OIDC standard endpoint) + async with httpx.AsyncClient() as client: + headers = {"Authorization": f"Bearer {access_token}"} + response = await client.get( + self.USERINFO_URL, + headers=headers + ) + + if response.status_code != 200: + raise HTTPException(status_code=500, detail=f"Error fetching LinkedIn user info: {response.text}") + + user_info = response.json() + + # The user info from the OIDC userinfo endpoint should already contain the + # email if requested in the scope, no need for a separate call + + return user_info + + except GetAccessTokenError as e: + error_description = e.args[0] + raise HTTPException(status_code=400, detail=f"LinkedIn OAuth error: {error_description}") + except Exception as e: + print(f"Error getting LinkedIn user info: {str(e)}") + raise HTTPException(status_code=500, detail=f"OAuth error: {str(e)}") + + def get_normalized_user_data(self, user_info: Dict[str, Any]) -> Dict[str, Any]: + # LinkedIn OpenID Connect response normalization + return { + "id": user_info.get("sub", ""), # 'sub' is the standard OIDC subject identifier + "email": user_info.get("email"), + "name": user_info.get("name"), + "first_name": user_info.get("given_name"), + "last_name": user_info.get("family_name"), + "picture": user_info.get("picture"), + "raw": user_info + } + +class OAuthManager: + """ + Manager class for handling multiple OAuth providers + """ + + def __init__(self): + self.providers: Dict[str, OAuthProvider] = {} + self.register_default_providers() + + def register_default_providers(self): + """Register all available providers""" + self.register_provider(AuthentikOAuth()) + self.register_provider(GoogleOAuth()) + self.register_provider(FacebookOAuth()) + self.register_provider(GitHubOAuth()) + self.register_provider(MicrosoftOAuth()) + self.register_provider(DiscordOAuth()) + self.register_provider(LinkedInOAuth()) + + def register_provider(self, provider: OAuthProvider): + """Register a new provider""" + self.providers[provider.provider_id] = provider + + def get_provider(self, provider_id: str) -> Optional[OAuthProvider]: + """Get a provider by ID""" + return self.providers.get(provider_id) + + def get_available_providers(self) -> List[Dict[str, Any]]: + """ + Get list of available providers (those with credentials configured) + Returns a list of provider details for UI rendering + """ + available_providers = [] + + for provider_id, provider in self.providers.items(): + if provider.client is not None: + available_providers.append({ + "id": provider.provider_id, + "name": provider.display_name, + "icon": provider.icon_class, + "color": provider.button_color + }) + + return available_providers + + async def get_login_url(self, request: Request, provider_id: str, redirect_uri: str) -> str: + """Get login URL for a specific provider""" + provider = self.get_provider(provider_id) + if not provider: + raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_id}") + + return await provider.get_login_url(request, redirect_uri) + + async def get_user_info(self, request: Request, provider_id: str, + redirect_uri: str, code: str) -> Dict[str, Any]: + """Get user info from a specific provider""" + provider = self.get_provider(provider_id) + if not provider: + raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_id}") + + # Get raw user info + user_info = await provider.get_user_info(request, redirect_uri, code) + + # Normalize it + return provider.get_normalized_user_data(user_info) + +# Create the OAuth manager for the application to use +oauth_manager = OAuthManager() + +# For backwards compatibility +authentik_oauth = oauth_manager.get_provider("authentik") diff --git a/app/auth/permissions.py b/app/auth/permissions.py new file mode 100644 index 0000000..2c38e32 --- /dev/null +++ b/app/auth/permissions.py @@ -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 \ No newline at end of file diff --git a/app/db_init.py b/app/db_init.py index 4bf2473..64c176a 100644 --- a/app/db_init.py +++ b/app/db_init.py @@ -10,7 +10,8 @@ from sqlalchemy.orm import Session from passlib.context import CryptContext from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event -from .db import SessionLocal +from .db import SessionLocal, engine +from .db_migrations import run_migrations pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") @@ -25,6 +26,14 @@ def table_has_column(engine, table_name, column_name): columns = [col['name'] for col in inspector.get_columns(table_name)] return column_name in columns +def init_db(): + """Initialize the database, applying migrations and seeding data.""" + # First, run any needed migrations + run_migrations(engine) + + # Then proceed with seeding if needed + seed_db() + def seed_db(): """Seed the database with test data.""" db = SessionLocal() @@ -288,4 +297,4 @@ def seed_db(): db.close() if __name__ == "__main__": - seed_db() + init_db() diff --git a/app/db_migrations.py b/app/db_migrations.py index 8a3599e..810f14d 100644 --- a/app/db_migrations.py +++ b/app/db_migrations.py @@ -1,4 +1,4 @@ -from sqlalchemy import text, inspect +from sqlalchemy import text, inspect, Column, String, JSON, MetaData, Table from .db import engine def table_exists(conn, table_name): @@ -90,3 +90,88 @@ def apply_migrations(): except Exception as e: print(f"Error applying migrations: {str(e)}") + +def run_migrations(engine): + """ + Run database migrations that can't be handled by SQLAlchemy's create_all() + """ + # Create a MetaData object + metadata = MetaData() + metadata.bind = engine + connection = engine.connect() + + try: + print("Running migrations...") + + # Check if the columns already exist before adding them + # Add additional_oauth_providers column if it doesn't exist + add_oauth_providers_column(connection) + + # Add first_name and last_name columns if they don't exist + add_name_columns(connection) + + print("Migrations completed successfully") + + except Exception as e: + print(f"Error during migrations: {str(e)}") + finally: + connection.close() + +def add_oauth_providers_column(connection): + """Add additional_oauth_providers column to users table""" + try: + # Use database-agnostic way to check if column exists + inspector = inspect(engine) + columns = [col['name'] for col in inspector.get_columns('users')] + + if 'additional_oauth_providers' not in columns: + print("Adding additional_oauth_providers column to users table") + + # Add column with database-specific syntax + if engine.name == 'sqlite': + connection.execute(text(""" + ALTER TABLE users + ADD COLUMN additional_oauth_providers JSON + """)) + else: # MySQL + connection.execute(text(""" + ALTER TABLE users + ADD COLUMN additional_oauth_providers JSON NULL + """)) + + connection.commit() + else: + print("Column additional_oauth_providers already exists") + except Exception as e: + print(f"Error adding additional_oauth_providers column: {str(e)}") + +def add_name_columns(connection): + """Add first_name and last_name columns to users table""" + try: + # Use database-agnostic way to check if columns exist + inspector = inspect(engine) + columns = [col['name'] for col in inspector.get_columns('users')] + + # Add first_name if needed + if 'first_name' not in columns: + print("Adding first_name column to users table") + connection.execute(text(""" + ALTER TABLE users + ADD COLUMN first_name VARCHAR(50) NULL + """)) + connection.commit() + else: + print("Column first_name already exists") + + # Add last_name if needed + if 'last_name' not in columns: + print("Adding last_name column to users table") + connection.execute(text(""" + ALTER TABLE users + ADD COLUMN last_name VARCHAR(50) NULL + """)) + connection.commit() + else: + print("Column last_name already exists") + except Exception as e: + print(f"Error adding name columns: {str(e)}") diff --git a/app/main.py b/app/main.py index 28bdecb..61b9ca7 100644 --- a/app/main.py +++ b/app/main.py @@ -7,16 +7,17 @@ from fastapi.middleware.cors import CORSMiddleware from pathlib import Path import os from starlette.middleware.sessions import SessionMiddleware +from starlette.middleware.authentication import AuthenticationMiddleware from dotenv import load_dotenv import logging import contextlib -from .db import init_db, engine, get_db +from .db import engine, get_db from . import models from .templates_config import templates from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience -from .db_init import seed_db -from .db_migrations import apply_migrations +from .db_init import init_db +from .auth.middleware import SessionAuthBackend, on_auth_error # Configure logging logging.basicConfig(level=logging.INFO) @@ -37,8 +38,18 @@ app.add_middleware( allow_headers=["*"], ) -# Add SessionMiddleware with a secure secret key +# Get secret key for the session SECRET_KEY = os.getenv("SECRET_KEY", "a-very-secure-secret-key-for-development") + +# Important: Order of middleware matters! +# First add AuthenticationMiddleware +app.add_middleware( + AuthenticationMiddleware, + backend=SessionAuthBackend(), + on_error=on_auth_error +) + +# Then add SessionMiddleware (last added = first executed) app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY) # Initialize database on startup @@ -49,19 +60,9 @@ async def startup_db_client(): # Import models to ensure they're registered with Base before initialization from . import models - # Create all tables first + # Initialize database (applies migrations and seeds data) init_db() - logger.info("Base tables created successfully") - - # Apply migrations to add additional columns and constraints - with contextlib.suppress(Exception): - apply_migrations() - logger.info("Migrations applied successfully") - - # Seed the database with test data if needed - with contextlib.suppress(Exception): - seed_db() - logger.info("Database seeded successfully") + logger.info("Database initialized and migrated successfully") except Exception as e: logger.error(f"Database initialization error: {str(e)}") diff --git a/app/models.py b/app/models.py index 7810f2c..892a90f 100644 --- a/app/models.py +++ b/app/models.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 -from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float, UniqueConstraint +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float, UniqueConstraint, JSON from sqlalchemy.orm import relationship from sqlalchemy.sql import func from sqlalchemy.ext.declarative import declarative_base from datetime import datetime +from starlette.authentication import BaseUser # This Base should be the single source of truth Base = declarative_base() -class User(Base): +class User(Base, BaseUser): __tablename__ = "users" id = Column(Integer, primary_key=True, index=True) username = Column(String(50), unique=True, index=True, nullable=False) @@ -29,7 +30,14 @@ class User(Base): # OAuth fields is_oauth_user = Column(Boolean, default=False) oauth_id = Column(String(255), nullable=True) - oauth_provider = Column(String(50), nullable=True) + oauth_provider = Column(String(50), nullable=True) # Primary OAuth provider + + # New field for multiple providers: store as JSON {provider_name: provider_user_id} + additional_oauth_providers = Column(JSON, nullable=True) + + # Profile fields + first_name = Column(String(50), nullable=True) + last_name = Column(String(50), nullable=True) picture = Column(String(255), nullable=True) # URL to profile picture # Relationships @@ -38,6 +46,22 @@ class User(Base): events_attended = relationship("EventAttendee", back_populates="user") owned_teams = relationship("Team", back_populates="owner") + # BaseUser interface implementation + @property + def is_authenticated(self) -> bool: + """Return True as this user is authenticated.""" + return True + + @property + def display_name(self) -> str: + """Return the display name for this user.""" + return self.username + + @property + def identity(self) -> str: + """Return the identity of this user.""" + return str(self.id) + def __repr__(self): return f"" diff --git a/app/templates/auth/login.html b/app/templates/auth/login.html index 61c6225..131d0fe 100644 --- a/app/templates/auth/login.html +++ b/app/templates/auth/login.html @@ -87,27 +87,34 @@ - {% if show_oauth %} -
-

Or sign in with

- -
- {% else %} + {% if oauth_providers and oauth_providers|length > 0 %}

Or sign in with

+ + + {% if oauth_providers|length <= 2 %}
- - + {% for provider in oauth_providers %} + + {{ provider.name }} + + {% endfor %}
-

OAuth login currently disabled

+ + + {% else %} +
+ {% for provider in oauth_providers %} + + {{ provider.name }} + + {% endfor %} +
+ {% endif %}
{% endif %} diff --git a/app/utils/auth.py b/app/utils/auth.py index 99ee6f3..09e4093 100644 --- a/app/utils/auth.py +++ b/app/utils/auth.py @@ -1,15 +1,19 @@ """ Authentication utilities for LeagueLedger. +This module provides backward compatibility with the existing code while leveraging +the new Starlette authentication system. """ from typing import Optional from fastapi import Request, Depends from sqlalchemy.orm import Session from ..db import get_db from ..models import User, TeamMembership +from starlette.authentication import UnauthenticatedUser async def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]: """ - Get the current authenticated user from the session. + Get the current authenticated user from the request. + Now uses request.user from Starlette authentication with fallback to session. Args: request: The FastAPI request object @@ -18,6 +22,11 @@ async def get_current_user(request: Request, db: Session = Depends(get_db)) -> O Returns: User object if authenticated, None otherwise """ + # First try to get user from Starlette authentication + if hasattr(request, "user") and request.user.is_authenticated: + return request.user + + # Fallback to session-based authentication (for backward compatibility) user_id = request.session.get("user_id") if not user_id: @@ -61,7 +70,7 @@ async def is_team_captain( is_captain = db.query(TeamMembership).filter( TeamMembership.team_id == team_id, TeamMembership.user_id == user.id, - TeamMembership.role == "captain" + TeamMembership.is_captain == True # Changed from role="captain" to is_captain=True ).first() return bool(is_captain) @@ -69,6 +78,7 @@ async def is_team_captain( async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool: """ Check if the current user is an admin. + Now checks Starlette auth scopes first. Args: request: FastAPI request object @@ -77,6 +87,11 @@ async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool: Returns: True if the user is an admin, False otherwise """ + # Check for 'admin' scope in Starlette auth + if hasattr(request, "auth") and request.auth and "admin" in request.auth.scopes: + return True + + # Fallback to user object check user = await get_current_user(request, db) if not user: @@ -97,6 +112,11 @@ async def requires_login(request: Request, db: Session = Depends(get_db)) -> Opt """ from fastapi import HTTPException, status + # Check Starlette auth first + if hasattr(request, "user") and request.user.is_authenticated: + return request.user + + # Fallback to session check user = await get_current_user(request, db) if not user: @@ -121,6 +141,12 @@ async def requires_admin(request: Request, db: Session = Depends(get_db)) -> Use """ from fastapi import HTTPException, status + # Check for admin scope in Starlette auth + if hasattr(request, "auth") and request.auth and "admin" in request.auth.scopes: + if hasattr(request, "user") and request.user.is_authenticated: + return request.user + + # Fallback to user object check user = await get_current_user(request, db) if not user or not user.is_admin: @@ -130,3 +156,6 @@ async def requires_admin(request: Request, db: Session = Depends(get_db)) -> Use ) return user + +# Note: For new code, consider using the decorators in app.auth.permissions instead +# of these dependency functions directly diff --git a/app/views/admin.py b/app/views/admin.py index 1db5067..0b1c23b 100644 --- a/app/views/admin.py +++ b/app/views/admin.py @@ -16,6 +16,7 @@ from ..models import ( OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints ) from ..templates_config import templates +from ..auth.permissions import require_admin router = APIRouter() @@ -74,25 +75,18 @@ def get_relationships(model_class: Type[Base]) -> Dict[str, str]: return relationships @router.get("/", response_class=HTMLResponse) +@require_admin(redirect_url="/auth/login?next=/admin/") async def admin_home(request: Request, db: Session = Depends(get_db)): """Admin dashboard home.""" - # Get user from session for navbar - user = None - user_id = request.session.get("user_id") - if user_id: - user = db.query(User).get(user_id) - - # Check admin status - if not user or not user.is_admin: - raise HTTPException(status_code=403, detail="Forbidden: Admin access required") - + # Since we're using Starlette's authentication, the user is now available in request.user model_list = [(key, name) for key, (_, name) in MODELS.items()] return templates.TemplateResponse( "admin/index.html", - {"request": request, "models": model_list, "user": user} + {"request": request, "models": model_list, "user": request.user} ) @router.get("/{model_name}", response_class=HTMLResponse) +@require_admin(redirect_url="/auth/login") async def list_records( request: Request, model_name: str, @@ -101,16 +95,6 @@ async def list_records( db: Session = Depends(get_db) ): """List records for a model with pagination.""" - # Get user from session for navbar - user = None - user_id = request.session.get("user_id") - if user_id: - user = db.query(User).get(user_id) - - # Check admin status - if not user or not user.is_admin: - raise HTTPException(status_code=403, detail="Forbidden: Admin access required") - if model_name not in MODELS: raise HTTPException(status_code=404, detail=f"Model {model_name} not found") @@ -150,27 +134,18 @@ async def list_records( "per_page": per_page, "total_pages": total_pages, "total_records": total_records, - "user": user # Add user to the context + "user": request.user # Add user to the context } ) @router.get("/{model_name}/new", response_class=HTMLResponse) +@require_admin(redirect_url="/auth/login") async def create_record_form( request: Request, model_name: str, db: Session = Depends(get_db) ): """Show form for creating a new record.""" - # Get user from session for navbar - user = None - user_id = request.session.get("user_id") - if user_id: - user = db.query(User).get(user_id) - - # Check admin status - if not user or not user.is_admin: - raise HTTPException(status_code=403, detail="Forbidden: Admin access required") - if model_name not in MODELS: raise HTTPException(status_code=404, detail=f"Model {model_name} not found") @@ -201,11 +176,12 @@ async def create_record_form( "record": None, # No record for new form "foreign_key_options": foreign_key_options, "is_new": True, - "user": user # Add user to the context + "user": request.user # Use request.user from Starlette authentication } ) @router.post("/{model_name}/new") +@require_admin(redirect_url="/auth/login") async def create_record( request: Request, model_name: str, @@ -254,6 +230,7 @@ async def create_record( return RedirectResponse(f"/admin/{model_name}", status_code=303) @router.get("/{model_name}/{record_id}", response_class=HTMLResponse) +@require_admin(redirect_url="/auth/login") async def edit_record_form( request: Request, model_name: str, @@ -261,16 +238,6 @@ async def edit_record_form( db: Session = Depends(get_db) ): """Show form for editing an existing record.""" - # Get user from session for navbar - user = None - user_id = request.session.get("user_id") - if user_id: - user = db.query(User).get(user_id) - - # Check admin status - if not user or not user.is_admin: - raise HTTPException(status_code=403, detail="Forbidden: Admin access required") - if model_name not in MODELS: raise HTTPException(status_code=404, detail=f"Model {model_name} not found") @@ -311,11 +278,12 @@ async def edit_record_form( "record": record_data, "foreign_key_options": foreign_key_options, "is_new": False, - "user": user # Add user to the context + "user": request.user # Use request.user from Starlette authentication } ) @router.post("/{model_name}/{record_id}") +@require_admin(redirect_url="/auth/login") async def update_record( request: Request, model_name: str, @@ -364,6 +332,7 @@ async def update_record( return RedirectResponse(f"/admin/{model_name}", status_code=303) @router.get("/{model_name}/{record_id}/delete") +@require_admin(redirect_url="/auth/login") async def delete_record( request: Request, model_name: str, diff --git a/app/views/auth.py b/app/views/auth.py index 3365e4e..4907544 100644 --- a/app/views/auth.py +++ b/app/views/auth.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Request, Depends, Form, HTTPException, status, BackgroundTasks from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates -from typing import Optional +from typing import Optional, List, Dict, Any import secrets import os import uuid @@ -12,7 +12,7 @@ from datetime import datetime, timedelta from ..db import get_db from ..models import User -from ..auth.oauth import authentik_oauth +from ..auth.oauth import oauth_manager from ..templates_config import templates from ..security import verify_password, get_password_hash from ..utils.mail import send_password_reset_email @@ -31,11 +31,18 @@ async def login_page(request: Request, error: Optional[str] = None, message: Opt if not message: message = "You are already logged in." + # Get available OAuth providers for the login screen + oauth_providers = oauth_manager.get_available_providers() + return templates.TemplateResponse( "auth/login.html", - {"request": request, "error": error, "message": message, - "show_oauth": True, "oauth_provider_name": "Authentik", - "user": user} # Add user to context + { + "request": request, + "error": error, + "message": message, + "user": user, + "oauth_providers": oauth_providers + } ) @router.post("/login", response_class=HTMLResponse) @@ -71,8 +78,7 @@ async def login_post( { "request": request, "error": "Please verify your email address before logging in", - "show_oauth": True, - "oauth_provider_name": "Authentik", + "oauth_providers": oauth_manager.get_available_providers(), "unverified_user_id": user.id, "unverified_email": user.email } @@ -85,8 +91,7 @@ async def login_post( { "request": request, "error": error, - "show_oauth": True, - "oauth_provider_name": "Authentik" + "oauth_providers": oauth_manager.get_available_providers() } ) @@ -317,21 +322,21 @@ async def resend_verification( status_code=HTTP_303_SEE_OTHER ) -@router.get("/oauth-login") -async def oauth_login(request: Request): - """Start the OAuth login flow""" +@router.get("/oauth-login/{provider_id}") +async def oauth_login(request: Request, provider_id: str): + """Start the OAuth login flow for the specified provider""" # Generate the redirect URI base_url = str(request.base_url) - redirect_uri = f"{base_url}auth/oauth-callback" + redirect_uri = f"{base_url}auth/oauth-callback/{provider_id}" - # Request a login URL from the Authentik provider + # Request a login URL from the provider try: # Set a session ID to validate the callback if "session_id" not in request.session: request.session["session_id"] = str(uuid.uuid4()) - # Get the authorization URL - make sure to await it - auth_url = await authentik_oauth.get_login_url(request, redirect_uri) + # Get the authorization URL + auth_url = await oauth_manager.get_login_url(request, provider_id, redirect_uri) # Redirect to the authorization URL return RedirectResponse(auth_url) @@ -342,9 +347,16 @@ async def oauth_login(request: Request): status_code=HTTP_303_SEE_OTHER ) -@router.get("/oauth-callback") -async def oauth_callback(request: Request, code: Optional[str] = None, state: Optional[str] = None, error: Optional[str] = None, db: Session = Depends(get_db)): - """Handle the OAuth callback""" +@router.get("/oauth-callback/{provider_id}") +async def oauth_callback( + request: Request, + provider_id: str, + code: Optional[str] = None, + state: Optional[str] = None, + error: Optional[str] = None, + db: Session = Depends(get_db) +): + """Handle the OAuth callback for the specified provider""" if error: return RedirectResponse( f"/auth/login?error=OAuth+login+failed:+{error}", @@ -359,11 +371,11 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op # Generate the redirect URI that matches the one used in the initial request base_url = str(request.base_url) - redirect_uri = f"{base_url}auth/oauth-callback" + redirect_uri = f"{base_url}auth/oauth-callback/{provider_id}" try: - # Get user info from the provider - user_info = await authentik_oauth.get_user_info(request, redirect_uri, code) + # Get normalized user info from the provider + user_info = await oauth_manager.get_user_info(request, provider_id, redirect_uri, code) if not user_info: return RedirectResponse( @@ -371,24 +383,43 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op status_code=HTTP_303_SEE_OTHER ) - # Extract user details from OAuth info - sub = user_info.get("sub", "") - email = user_info.get("email", "") - name = user_info.get("preferred_username", "") or user_info.get("name", "") or email.split("@")[0] - picture = user_info.get("picture", None) + # Extract user details from the normalized OAuth info + provider_user_id = user_info.get("id") + email = user_info.get("email") + name = user_info.get("name") or (email.split("@")[0] if email else f"user_{provider_id}") + first_name = user_info.get("first_name") + last_name = user_info.get("last_name") + picture = user_info.get("picture") - # Check if the user already exists + if not email: + return RedirectResponse( + "/auth/login?error=Email+address+not+provided+by+the+OAuth+provider", + status_code=HTTP_303_SEE_OTHER + ) + + # Check if the user already exists with this email user = db.query(User).filter(User.email == email).first() if not user: - print(f"Creating new user with email {email} and username {name}") + # Set username to be unique if it already exists + username = name + base_username = username + counter = 1 + + # Check if username already exists + while db.query(User).filter(User.username == username).first(): + username = f"{base_username}{counter}" + counter += 1 + # Create a new user user = User( - username=name, + username=username, email=email, - oauth_id=sub, + oauth_id=provider_user_id, is_oauth_user=True, - oauth_provider="authentik", + oauth_provider=provider_id, + first_name=first_name, + last_name=last_name, picture=picture, is_verified=True # OAuth users are considered verified ) @@ -399,13 +430,27 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op # Update existing user's OAuth information if not user.is_oauth_user: user.is_oauth_user = True - user.oauth_id = sub - user.oauth_provider = "authentik" + user.oauth_id = provider_user_id - # Update profile picture if available + # If user has a different OAuth provider, add this one as additional + if user.oauth_provider and user.oauth_provider != provider_id: + if not user.additional_oauth_providers: + user.additional_oauth_providers = {} + user.additional_oauth_providers[provider_id] = provider_user_id + else: + user.oauth_provider = provider_id + + # Update profile info if not already set + if first_name and not user.first_name: + user.first_name = first_name + if last_name and not user.last_name: + user.last_name = last_name if picture and not user.picture: user.picture = picture + # Update last login time + user.last_login = datetime.utcnow() + db.commit() # Set session data @@ -413,8 +458,11 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op request.session["username"] = user.username request.session["is_authenticated"] = True request.session["is_admin"] = user.is_admin + request.session["oauth_provider"] = provider_id # Store the provider for possible UI customization - return RedirectResponse("/dashboard", status_code=HTTP_303_SEE_OTHER) + # Redirect to dashboard or the requested next page + next_page = request.query_params.get("next", "/dashboard") + return RedirectResponse(next_page, status_code=HTTP_303_SEE_OTHER) except Exception as e: print(f"OAuth callback error: {str(e)}") @@ -423,6 +471,36 @@ async def oauth_callback(request: Request, code: Optional[str] = None, state: Op status_code=HTTP_303_SEE_OTHER ) +# Legacy route for backward compatibility +@router.get("/oauth-login") +async def legacy_oauth_login(request: Request): + """Redirect to Authentik OAuth login for backward compatibility""" + return RedirectResponse("/auth/oauth-login/authentik", status_code=HTTP_302_FOUND) + +@router.get("/oauth-callback") +async def legacy_oauth_callback( + request: Request, + code: Optional[str] = None, + state: Optional[str] = None, + error: Optional[str] = None, + db: Session = Depends(get_db) +): + """Redirect to Authentik OAuth callback handler for backward compatibility""" + params = [] + if code: + params.append(f"code={code}") + if state: + params.append(f"state={state}") + if error: + params.append(f"error={error}") + + query_string = "&".join(params) + redirect_url = f"/auth/oauth-callback/authentik" + if query_string: + redirect_url = f"{redirect_url}?{query_string}" + + return RedirectResponse(redirect_url, status_code=HTTP_302_FOUND) + @router.get("/logout") async def logout(request: Request): """Log out the user""" diff --git a/app/views/teams.py b/app/views/teams.py index 03bb55a..ec2a970 100644 --- a/app/views/teams.py +++ b/app/views/teams.py @@ -18,6 +18,7 @@ from ..schemas import TeamCreate from ..templates_config import templates from ..utils.auth import get_current_user, is_team_captain from ..utils.mail import send_team_join_request_notification, send_join_request_response +from ..auth.permissions import require_auth, require_team_captain router = APIRouter() @@ -605,3 +606,61 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)): "is_open": is_open # Pass is_open to the template } ) + +@router.get("/{team_id}/manage", response_class=HTMLResponse) +@require_team_captain() +async def manage_team(request: Request, team_id: int, db: Session = Depends(get_db)): + """ + Team management page for captains. + This route is protected by the require_team_captain decorator which + automatically checks if the user is a captain of the team. + """ + # Get team + team = db.query(Team).filter_by(id=team_id).first() + if not team: + raise HTTPException(status_code=404, detail="Team not found") + + # Get pending join requests + join_requests = db.query(TeamJoinRequest).filter( + TeamJoinRequest.team_id == team_id, + TeamJoinRequest.status == "pending" + ).all() + + # Get requesters' info + pending_requests = [] + for req in join_requests: + requester = db.query(User).filter(User.id == req.user_id).first() + if requester: + pending_requests.append({ + "request": req, + "user": requester + }) + + # Get team members + memberships = db.query(TeamMembership).filter_by(team_id=team_id).all() + team_members = [] + + for membership in memberships: + member = db.query(User).filter_by(id=membership.user_id).first() + if member: + team_members.append({ + "user": member, + "is_admin": membership.is_admin, + "is_captain": membership.is_captain + }) + + # Get current user from session for template + current_user = None + if hasattr(request, "session") and request.session.get("user_id"): + current_user = db.query(User).filter_by(id=request.session.get("user_id")).first() + + return templates.TemplateResponse( + "teams/manage.html", + { + "request": request, + "team": team, + "pending_requests": pending_requests, + "team_members": team_members, + "user": current_user # Use current user from session instead of request.user + } + ) diff --git a/docker-compose.yml b/docker-compose.yml index b50aba8..c59c391 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,15 +28,18 @@ services: db: condition: service_healthy environment: + # Database configuration DB_HOST: "db" DB_PORT: "3306" DB_NAME: "pubquiz_db" DB_USER: "pubquiz_user" DB_PASS: "pubquiz_pass" PYTHONUNBUFFERED: "1" - # Use environment variable from .env instead of hard-coding + + # Base URL LEAGUELEDGER_BASE_URL: ${LEAGUELEDGER_BASE_URL:-http://localhost:8000} - # Email configuration for Mailhog + + # Email configuration for Mailpit MAIL_USERNAME: "" MAIL_PASSWORD: "" MAIL_FROM: "noreply@leagueledger.net" @@ -47,6 +50,37 @@ services: MAIL_SSL_TLS: "False" MAIL_USE_CREDENTIALS: "False" MAIL_VALIDATE_CERTS: "False" + + # OAuth configuration - read from .env file + # Authentik + AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID:-} + AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET:-} + AUTHENTIK_CONFIG_URL: ${AUTHENTIK_CONFIG_URL:-} + + # Google OAuth + GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} + GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} + + # GitHub OAuth + GITHUB_CLIENT_ID: ${GITHUB_CLIENT_ID:-} + GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET:-} + + # Facebook OAuth + FACEBOOK_CLIENT_ID: ${FACEBOOK_CLIENT_ID:-} + FACEBOOK_CLIENT_SECRET: ${FACEBOOK_CLIENT_SECRET:-} + + # Microsoft OAuth + MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-} + MICROSOFT_CLIENT_SECRET: ${MICROSOFT_CLIENT_SECRET:-} + MICROSOFT_TENANT: ${MICROSOFT_TENANT:-common} + + # Discord OAuth + DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:-} + DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:-} + + # LinkedIn OAuth + LINKEDIN_CLIENT_ID: ${LINKEDIN_CLIENT_ID:-} + LINKEDIN_CLIENT_SECRET: ${LINKEDIN_CLIENT_SECRET:-} command: uvicorn app.main:app --host 0.0.0.0 --reload ports: - "8000:8000" diff --git a/docs/administration/admin-panel.md b/docs/administration/admin-panel.md new file mode 100644 index 0000000..c97f1b0 --- /dev/null +++ b/docs/administration/admin-panel.md @@ -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) \ No newline at end of file diff --git a/docs/deployment/docker.md b/docs/deployment/docker.md new file mode 100644 index 0000000..64d1000 --- /dev/null +++ b/docs/deployment/docker.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 \ No newline at end of file diff --git a/docs/development/architecture.md b/docs/development/architecture.md new file mode 100644 index 0000000..a9ddf9f --- /dev/null +++ b/docs/development/architecture.md @@ -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) \ No newline at end of file diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..25b72ce --- /dev/null +++ b/docs/getting-started/installation.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 \ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..c81a51a --- /dev/null +++ b/docs/index.md @@ -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. \ No newline at end of file diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..4bb2d90 --- /dev/null +++ b/docs/requirements.txt @@ -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 \ No newline at end of file diff --git a/docs/social_login_setup.md b/docs/social_login_setup.md new file mode 100644 index 0000000..fdea28f --- /dev/null +++ b/docs/social_login_setup.md @@ -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. \ No newline at end of file diff --git a/docs/user-guide/overview.md b/docs/user-guide/overview.md new file mode 100644 index 0000000..e3b9636 --- /dev/null +++ b/docs/user-guide/overview.md @@ -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 \ No newline at end of file diff --git a/docs/user-guide/qr-codes.md b/docs/user-guide/qr-codes.md new file mode 100644 index 0000000..6304c4e --- /dev/null +++ b/docs/user-guide/qr-codes.md @@ -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 \ No newline at end of file diff --git a/docs/user-guide/teams.md b/docs/user-guide/teams.md new file mode 100644 index 0000000..c60cb07 --- /dev/null +++ b/docs/user-guide/teams.md @@ -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 \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..ab957b0 --- /dev/null +++ b/mkdocs.yml @@ -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 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index fc0b859..9f7db79 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,3 +39,6 @@ Pillow>=9.0.0 # PDF generation for QR code sheets reportlab>=3.6.12 babel>=2.12.1 + +# JSON handling for additional OAuth providers storage +simplejson>=3.19.2