Implement user authentication and management system with FastAPI
- Added authentication routes for login, registration, password reset, and account deletion in `auth.py`. - Implemented user profile management, including updating user information and changing passwords. - Created dashboard views to display user-specific information and recent activity in `dashboard.py`. - Developed leaderboard views to show team rankings and points in `leaderboard.py`. - Added QR code generation and redemption functionality in `qr.py` and `redeem.py`. - Implemented team management features, allowing users to create and join teams in `teams.py`. - Introduced session debugging utility to assist with session-related issues in `debug_session.py`. - Configured Docker Compose for MySQL database and FastAPI application with environment variables. - Updated requirements.txt to include necessary dependencies for the application.
This commit is contained in:
+28
@@ -0,0 +1,28 @@
|
|||||||
|
# Use Python 3.11 (or whichever version you prefer)
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Create working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies needed for mysqlclient
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
pkg-config \
|
||||||
|
default-libmysqlclient-dev \
|
||||||
|
build-essential \
|
||||||
|
&& apt-get clean \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first (for caching)
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy the rest of the code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run the FastAPI app with Uvicorn
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Skeleton for authentication logic.
|
||||||
|
Placeholder for OAuth or password-based login.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from .db import SessionLocal
|
||||||
|
from . import models
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Add routes for login, logout, register, etc.
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
from sqlalchemy import create_engine, inspect, text
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base
|
||||||
|
|
||||||
|
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||||||
|
DB_PORT = os.getenv("DB_PORT", "3306")
|
||||||
|
DB_NAME = os.getenv("DB_NAME", "pubquiz_db")
|
||||||
|
DB_USER = os.getenv("DB_USER", "pubquiz_user")
|
||||||
|
DB_PASS = os.getenv("DB_PASS", "pubquiz_pass")
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = (
|
||||||
|
f"mysql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
||||||
|
)
|
||||||
|
|
||||||
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, pool_pre_ping=True)
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
def init_db():
|
||||||
|
"""Initialize the database with all tables."""
|
||||||
|
# Import all models to ensure they're loaded
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
# Create all tables if they don't exist
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Check for missing columns and add them
|
||||||
|
print("Checking for schema updates...")
|
||||||
|
migrate_schema()
|
||||||
|
|
||||||
|
def migrate_schema():
|
||||||
|
"""Apply schema migrations for existing tables."""
|
||||||
|
try:
|
||||||
|
connection = engine.connect()
|
||||||
|
inspector = inspect(engine)
|
||||||
|
|
||||||
|
# Check User table
|
||||||
|
if 'users' in inspector.get_table_names():
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('users')]
|
||||||
|
|
||||||
|
# Add all missing columns for User table
|
||||||
|
user_columns = {
|
||||||
|
'created_at': "ALTER TABLE users ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP",
|
||||||
|
'is_active': "ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT TRUE",
|
||||||
|
'is_verified': "ALTER TABLE users ADD COLUMN is_verified BOOLEAN DEFAULT FALSE",
|
||||||
|
'verification_token': "ALTER TABLE users ADD COLUMN verification_token VARCHAR(255)",
|
||||||
|
'reset_token': "ALTER TABLE users ADD COLUMN reset_token VARCHAR(255)",
|
||||||
|
'reset_token_expires_at': "ALTER TABLE users ADD COLUMN reset_token_expires_at TIMESTAMP NULL",
|
||||||
|
'last_login': "ALTER TABLE users ADD COLUMN last_login TIMESTAMP NULL",
|
||||||
|
'is_admin': "ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE" # Add is_admin column
|
||||||
|
}
|
||||||
|
|
||||||
|
for col_name, sql in user_columns.items():
|
||||||
|
if col_name not in columns:
|
||||||
|
print(f"Adding {col_name} column to users table")
|
||||||
|
try:
|
||||||
|
connection.execute(text(sql))
|
||||||
|
connection.commit()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error adding column {col_name}: {e}")
|
||||||
|
|
||||||
|
# Check Team table
|
||||||
|
if 'teams' in inspector.get_table_names():
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('teams')]
|
||||||
|
if 'is_public' not in columns:
|
||||||
|
print("Adding is_public column to teams table")
|
||||||
|
connection.execute(text(
|
||||||
|
"ALTER TABLE teams ADD COLUMN is_public BOOLEAN DEFAULT FALSE"
|
||||||
|
))
|
||||||
|
if 'created_at' not in columns:
|
||||||
|
print("Adding created_at column to teams table")
|
||||||
|
connection.execute(text(
|
||||||
|
"ALTER TABLE teams ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
|
||||||
|
))
|
||||||
|
if 'description' not in columns:
|
||||||
|
print("Adding description column to teams table")
|
||||||
|
connection.execute(text(
|
||||||
|
"ALTER TABLE teams ADD COLUMN description TEXT"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check TeamMembership table
|
||||||
|
if 'team_membership' in inspector.get_table_names():
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('team_membership')]
|
||||||
|
if 'joined_at' not in columns:
|
||||||
|
print("Adding joined_at column to team_membership table")
|
||||||
|
connection.execute(text(
|
||||||
|
"ALTER TABLE team_membership ADD COLUMN joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Check QRTicket table
|
||||||
|
if 'qr_tickets' in inspector.get_table_names():
|
||||||
|
columns = [col['name'] for col in inspector.get_columns('qr_tickets')]
|
||||||
|
if 'created_at' not in columns:
|
||||||
|
print("Adding created_at column to qr_tickets table")
|
||||||
|
connection.execute(text(
|
||||||
|
"ALTER TABLE qr_tickets ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP"
|
||||||
|
))
|
||||||
|
if 'redeemed_at' not in columns:
|
||||||
|
print("Adding redeemed_at column to qr_tickets table")
|
||||||
|
connection.execute(text(
|
||||||
|
"ALTER TABLE qr_tickets ADD COLUMN redeemed_at TIMESTAMP NULL"
|
||||||
|
))
|
||||||
|
if 'event_name' not in columns:
|
||||||
|
print("Adding event_name column to qr_tickets table")
|
||||||
|
connection.execute(text(
|
||||||
|
"ALTER TABLE qr_tickets ADD COLUMN event_name VARCHAR(255)"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Create OAuthAccount table if it doesn't exist
|
||||||
|
if 'oauth_accounts' not in inspector.get_table_names():
|
||||||
|
print("Creating oauth_accounts table")
|
||||||
|
connection.execute(text("""
|
||||||
|
CREATE TABLE oauth_accounts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT,
|
||||||
|
provider VARCHAR(50),
|
||||||
|
provider_user_id VARCHAR(255),
|
||||||
|
access_token VARCHAR(255),
|
||||||
|
expires_at TIMESTAMP NULL,
|
||||||
|
refresh_token VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
# Create TeamAchievement table if it doesn't exist
|
||||||
|
if 'team_achievements' not in inspector.get_table_names():
|
||||||
|
print("Creating team_achievements table")
|
||||||
|
connection.execute(text("""
|
||||||
|
CREATE TABLE team_achievements (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
team_id INT,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
event_name VARCHAR(255),
|
||||||
|
description TEXT,
|
||||||
|
achieved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (team_id) REFERENCES teams(id)
|
||||||
|
)
|
||||||
|
"""))
|
||||||
|
|
||||||
|
connection.commit()
|
||||||
|
print("Schema migrations completed successfully")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error during schema migration: {e}")
|
||||||
|
finally:
|
||||||
|
connection.close()
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Seed the database with initial testing data.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import User, Team, TeamMembership, QRTicket
|
||||||
|
from .db import SessionLocal
|
||||||
|
|
||||||
|
def table_has_column(engine, table_name, column_name):
|
||||||
|
"""Check if a table has a specific column."""
|
||||||
|
inspector = inspect(engine)
|
||||||
|
if table_name not in inspector.get_table_names():
|
||||||
|
return False
|
||||||
|
columns = [col['name'] for col in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
def seed_db():
|
||||||
|
"""Seed the database with test data."""
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Only seed if tables are empty
|
||||||
|
if db.query(User).count() > 0:
|
||||||
|
print("Database already has data. Skipping seeding.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create users
|
||||||
|
users = [
|
||||||
|
User(username="john_quizmaster", email="john@example.com", hashed_password="password123"),
|
||||||
|
User(username="sarah_johnson", email="sarah@example.com", hashed_password="password123"),
|
||||||
|
User(username="mike_peters", email="mike@example.com", hashed_password="password123"),
|
||||||
|
User(username="emma_wilson", email="emma@example.com", hashed_password="password123"),
|
||||||
|
User(username="robert_brown", email="robert@example.com", hashed_password="password123"),
|
||||||
|
]
|
||||||
|
db.add_all(users)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create teams
|
||||||
|
has_is_public = table_has_column(db.bind, 'teams', 'is_public')
|
||||||
|
has_created_at = table_has_column(db.bind, 'teams', 'created_at')
|
||||||
|
has_description = table_has_column(db.bind, 'teams', 'description')
|
||||||
|
|
||||||
|
teams = []
|
||||||
|
for i, name in enumerate(["Quiz Wizards", "Trivia Titans", "Beer Brainiacs", "Knowledge Knights"]):
|
||||||
|
team_attrs = {"name": name}
|
||||||
|
if has_is_public:
|
||||||
|
team_attrs["is_public"] = i % 2 == 1 # Alternate public/private
|
||||||
|
if has_description:
|
||||||
|
team_attrs["description"] = f"A team of quiz enthusiasts called {name}"
|
||||||
|
teams.append(Team(**team_attrs))
|
||||||
|
|
||||||
|
db.add_all(teams)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create team memberships
|
||||||
|
has_joined_at = table_has_column(db.bind, 'team_membership', 'joined_at')
|
||||||
|
|
||||||
|
memberships = []
|
||||||
|
membership_data = [
|
||||||
|
# Quiz Wizards
|
||||||
|
(1, 1, True, 160),
|
||||||
|
(2, 1, False, 155),
|
||||||
|
(3, 1, False, 130),
|
||||||
|
(4, 1, False, 90),
|
||||||
|
(5, 1, False, 45),
|
||||||
|
# Trivia Titans
|
||||||
|
(2, 2, True, 150),
|
||||||
|
(1, 2, False, 145),
|
||||||
|
# Beer Brainiacs
|
||||||
|
(3, 3, True, 120),
|
||||||
|
]
|
||||||
|
|
||||||
|
for user_id, team_id, is_admin, days_ago in membership_data:
|
||||||
|
membership_attrs = {
|
||||||
|
"user_id": user_id,
|
||||||
|
"team_id": team_id,
|
||||||
|
"is_admin": is_admin
|
||||||
|
}
|
||||||
|
if has_joined_at:
|
||||||
|
membership_attrs["joined_at"] = datetime.now() - timedelta(days=days_ago)
|
||||||
|
memberships.append(TeamMembership(**membership_attrs))
|
||||||
|
|
||||||
|
db.add_all(memberships)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create QR tickets
|
||||||
|
has_created_at = table_has_column(db.bind, 'qr_tickets', 'created_at')
|
||||||
|
has_redeemed_at = table_has_column(db.bind, 'qr_tickets', 'redeemed_at')
|
||||||
|
has_event_name = table_has_column(db.bind, 'qr_tickets', 'event_name')
|
||||||
|
|
||||||
|
event_names = [
|
||||||
|
"Music Trivia Night",
|
||||||
|
"History Night",
|
||||||
|
"Movie Trivia Night",
|
||||||
|
"Sports Quiz",
|
||||||
|
"General Knowledge"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Create some basic tickets
|
||||||
|
tickets = []
|
||||||
|
for i in range(15):
|
||||||
|
points = random.choice([5, 10, 15, 20, 25])
|
||||||
|
team_id = random.randint(1, len(teams))
|
||||||
|
user_id = random.randint(1, len(users))
|
||||||
|
|
||||||
|
ticket_attrs = {
|
||||||
|
"code": f"TICKET{i:03d}",
|
||||||
|
"points": points,
|
||||||
|
"redeemed_by": user_id,
|
||||||
|
"redeemed_at_team": team_id,
|
||||||
|
"used": True
|
||||||
|
}
|
||||||
|
|
||||||
|
if has_event_name:
|
||||||
|
ticket_attrs["event_name"] = random.choice(event_names)
|
||||||
|
|
||||||
|
tickets.append(QRTicket(**ticket_attrs))
|
||||||
|
|
||||||
|
db.add_all(tickets)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print("Database seeded successfully!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error seeding database: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
seed_db()
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
from fastapi import Depends, HTTPException, status, Request
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from .db import SessionLocal, engine
|
||||||
|
from .models import User
|
||||||
|
from .security import SECRET_KEY, ALGORITHM
|
||||||
|
from .templates_config import templates
|
||||||
|
|
||||||
|
# OAuth2 scheme for token authentication
|
||||||
|
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Database dependency."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
# Check if all required user columns exist
|
||||||
|
def get_available_user_columns():
|
||||||
|
inspector = inspect(engine)
|
||||||
|
if 'users' in inspector.get_table_names():
|
||||||
|
return [col['name'] for col in inspector.get_columns('users')]
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
|
||||||
|
"""Get the current authenticated user based on the access token."""
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# If no token, return None (not authenticated)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||||
|
username: str = payload.get("sub")
|
||||||
|
if username is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
if user is None:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
# Update last login time if column exists
|
||||||
|
columns = get_available_user_columns()
|
||||||
|
if 'last_login' in columns and hasattr(user, 'last_login'):
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def get_current_active_user(current_user: User = Depends(get_current_user)):
|
||||||
|
"""Check if the current user is active."""
|
||||||
|
if not current_user:
|
||||||
|
return None
|
||||||
|
|
||||||
|
columns = get_available_user_columns()
|
||||||
|
if 'is_active' in columns and hasattr(current_user, 'is_active') and not current_user.is_active:
|
||||||
|
raise HTTPException(status_code=400, detail="Inactive user")
|
||||||
|
|
||||||
|
return current_user
|
||||||
|
|
||||||
|
# Improved session-based user lookup with better error handling and logging
|
||||||
|
async def get_user_from_session(request: Request):
|
||||||
|
"""Get current user from session with improved error handling"""
|
||||||
|
try:
|
||||||
|
if not hasattr(request, "session"):
|
||||||
|
print("No session attribute in request")
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_id = request.session.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
print("No user_id in session")
|
||||||
|
return None
|
||||||
|
|
||||||
|
print(f"Looking up user with ID: {user_id}")
|
||||||
|
# Manually get a database session from get_db
|
||||||
|
db = next(get_db())
|
||||||
|
try:
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
print(f"User with ID {user_id} not found in database")
|
||||||
|
# Clear invalid session data
|
||||||
|
request.session.clear()
|
||||||
|
return None
|
||||||
|
return user
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting user from session: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Template context processor to add user to all templates
|
||||||
|
async def add_user_to_templates(request: Request):
|
||||||
|
"""Add current user to all template contexts."""
|
||||||
|
user = await get_user_from_session(request)
|
||||||
|
return {"current_user": user}
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from fastapi import FastAPI, Request, status
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .db import init_db, engine
|
||||||
|
from . import models
|
||||||
|
from .templates_config import templates
|
||||||
|
from .views import qr, redeem, teams, admin, leaderboard, dashboard, auth
|
||||||
|
from .db_init import seed_db
|
||||||
|
from .dependencies import get_user_from_session
|
||||||
|
|
||||||
|
# Create tables on startup
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Seed database with initial test data
|
||||||
|
# In a production app, you would handle this differently
|
||||||
|
seed_db()
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
|
||||||
|
# IMPORTANT: Add SessionMiddleware FIRST before any other middleware
|
||||||
|
# This ensures session data is available to all other middleware and route handlers
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key=os.environ.get("SECRET_KEY", "a-default-secret-key-for-sessions"),
|
||||||
|
max_age=int(os.environ.get("SESSION_MAX_AGE", 86400)), # 24 hours
|
||||||
|
same_site="lax", # Important for security while allowing redirects
|
||||||
|
https_only=os.environ.get("COOKIE_SECURE", "False").lower() == "true",
|
||||||
|
session_cookie="league_ledger_session", # Custom cookie name for clarity
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add debugging middleware to help track sessions
|
||||||
|
@app.middleware("http")
|
||||||
|
async def debug_session_middleware(request, call_next):
|
||||||
|
"""Debug middleware to track session state"""
|
||||||
|
session_cookie = request.cookies.get("league_ledger_session")
|
||||||
|
|
||||||
|
print(f"Request path: {request.url.path}")
|
||||||
|
print(f"Has session attribute: {'session' in request.scope}")
|
||||||
|
print(f"Has session cookie: {session_cookie is not None}")
|
||||||
|
|
||||||
|
if "session" in request.scope:
|
||||||
|
print(f"Session data before: {dict(request.session)}")
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
if "session" in request.scope:
|
||||||
|
print(f"Session data after: {dict(request.session)}")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Update the template globals at app startup to access the request
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_user_to_request(request: Request, call_next):
|
||||||
|
# Print debugging information
|
||||||
|
print(f"Processing request to: {request.url.path}")
|
||||||
|
|
||||||
|
# Add user to request state so templates can access it
|
||||||
|
try:
|
||||||
|
if "session" in request.scope:
|
||||||
|
print("Session found in request scope")
|
||||||
|
if "user_id" in request.session:
|
||||||
|
print(f"User ID in session: {request.session['user_id']}")
|
||||||
|
# Get user from session
|
||||||
|
user = await get_user_from_session(request)
|
||||||
|
request.state.user = user
|
||||||
|
else:
|
||||||
|
print("No user_id in session")
|
||||||
|
request.state.user = None
|
||||||
|
else:
|
||||||
|
print("No session in request scope")
|
||||||
|
request.state.user = None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in middleware: {e}")
|
||||||
|
request.state.user = None
|
||||||
|
|
||||||
|
# Update template context with current user before processing the request
|
||||||
|
templates.env.globals["current_user"] = request.state.user
|
||||||
|
|
||||||
|
# Process the request
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Routers
|
||||||
|
app.include_router(auth.router, prefix="/auth", tags=["Auth"]) # Auth router should be first
|
||||||
|
app.include_router(qr.router, prefix="/qr", tags=["QR"])
|
||||||
|
app.include_router(redeem.router, prefix="/redeem", tags=["Redeem"])
|
||||||
|
app.include_router(teams.router, prefix="/teams", tags=["Teams"])
|
||||||
|
app.include_router(admin.router, prefix="/admin", tags=["Admin"])
|
||||||
|
app.include_router(leaderboard.router, prefix="/leaderboard", tags=["Leaderboard"])
|
||||||
|
app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"])
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index(request: Request):
|
||||||
|
return templates.TemplateResponse("index.html", {
|
||||||
|
"request": request,
|
||||||
|
"now": datetime.now,
|
||||||
|
"current_user": getattr(request.state, "user", None)
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/about", response_class=HTMLResponse)
|
||||||
|
def about(request: Request):
|
||||||
|
return templates.TemplateResponse("about.html", {
|
||||||
|
"request": request,
|
||||||
|
"current_user": getattr(request.state, "user", None)
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/contact", response_class=HTMLResponse)
|
||||||
|
def contact(request: Request):
|
||||||
|
return templates.TemplateResponse("contact.html", {
|
||||||
|
"request": request,
|
||||||
|
"current_user": getattr(request.state, "user", None)
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/privacy", response_class=HTMLResponse)
|
||||||
|
def privacy(request: Request):
|
||||||
|
return templates.TemplateResponse("privacy.html", {
|
||||||
|
"request": request,
|
||||||
|
"current_user": getattr(request.state, "user", None)
|
||||||
|
})
|
||||||
|
|
||||||
|
@app.get("/terms", response_class=HTMLResponse)
|
||||||
|
def terms(request: Request):
|
||||||
|
return templates.TemplateResponse("terms.html", {
|
||||||
|
"request": request,
|
||||||
|
"current_user": getattr(request.state, "user", None)
|
||||||
|
})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Table
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
from .db import Base
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(50), unique=True, index=True, nullable=False)
|
||||||
|
email = Column(String(255), unique=True, index=True, nullable=False)
|
||||||
|
hashed_password = Column(String(255), nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
is_verified = Column(Boolean, default=False)
|
||||||
|
verification_token = Column(String(255), nullable=True)
|
||||||
|
reset_token = Column(String(255), nullable=True)
|
||||||
|
reset_token_expires_at = Column(DateTime, nullable=True)
|
||||||
|
last_login = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Relationship to teams
|
||||||
|
memberships = relationship("TeamMembership", back_populates="user")
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthAccount(Base):
|
||||||
|
__tablename__ = "oauth_accounts"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"))
|
||||||
|
provider = Column(String(50)) # e.g., "google", "facebook"
|
||||||
|
provider_user_id = Column(String(255))
|
||||||
|
access_token = Column(String(255))
|
||||||
|
expires_at = Column(DateTime, nullable=True)
|
||||||
|
refresh_token = Column(String(255), nullable=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
|
||||||
|
class Team(Base):
|
||||||
|
__tablename__ = "teams"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(100), unique=True, nullable=False)
|
||||||
|
|
||||||
|
# Add fields for team detail view
|
||||||
|
is_public = Column(Boolean, default=False) # For team privacy setting
|
||||||
|
created_at = Column(DateTime, server_default=func.now()) # For team founded date
|
||||||
|
description = Column(Text, nullable=True) # Optional team description
|
||||||
|
|
||||||
|
# Relationship to memberships
|
||||||
|
memberships = relationship("TeamMembership", back_populates="team")
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMembership(Base):
|
||||||
|
__tablename__ = "team_membership"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"))
|
||||||
|
team_id = Column(Integer, ForeignKey("teams.id"))
|
||||||
|
is_admin = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Add joined_at to track when members joined
|
||||||
|
joined_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
user = relationship("User", back_populates="memberships")
|
||||||
|
team = relationship("Team", back_populates="memberships")
|
||||||
|
|
||||||
|
|
||||||
|
class QRTicket(Base):
|
||||||
|
__tablename__ = "qr_tickets"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
code = Column(String(128), unique=True, index=True) # Unique token
|
||||||
|
points = Column(Integer, default=0)
|
||||||
|
redeemed_by = Column(Integer, ForeignKey("users.id"), nullable=True)
|
||||||
|
redeemed_at_team = Column(Integer, ForeignKey("teams.id"), nullable=True)
|
||||||
|
used = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Add timestamps to track when tickets were created and redeemed
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
redeemed_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# Add event name to track which quiz event this ticket belongs to
|
||||||
|
event_name = Column(String(255), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
# New model for team achievements
|
||||||
|
class TeamAchievement(Base):
|
||||||
|
__tablename__ = "team_achievements"
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
team_id = Column(Integer, ForeignKey("teams.id"))
|
||||||
|
name = Column(String(255), nullable=False) # e.g., "1st Place"
|
||||||
|
event_name = Column(String(255), nullable=True) # e.g., "History Night"
|
||||||
|
description = Column(Text, nullable=True)
|
||||||
|
achieved_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
team = relationship("Team")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Add is_admin field to User model if it's missing
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, Boolean
|
||||||
|
# ...existing imports...
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
# ...existing fields...
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
username = Column(String(50), unique=True, index=True)
|
||||||
|
email = Column(String(100), unique=True, index=True)
|
||||||
|
password = Column(String(255))
|
||||||
|
|
||||||
|
# Add is_admin field if it doesn't exist
|
||||||
|
is_admin = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
# ...existing methods...
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# User schemas
|
||||||
|
class UserBase(BaseModel):
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
# Validators could be added here, like:
|
||||||
|
@field_validator('password')
|
||||||
|
def password_must_be_strong(cls, v):
|
||||||
|
if len(v) < 8:
|
||||||
|
raise ValueError('Password must be at least 8 characters')
|
||||||
|
return v
|
||||||
|
|
||||||
|
class UserLogin(BaseModel):
|
||||||
|
username_or_email: str
|
||||||
|
password: str
|
||||||
|
remember_me: bool = False
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
email: Optional[EmailStr] = None
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
email: EmailStr
|
||||||
|
created_at: datetime
|
||||||
|
is_active: bool
|
||||||
|
is_verified: bool
|
||||||
|
last_login: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class Token(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str
|
||||||
|
user_id: int
|
||||||
|
username: str
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
username: Optional[str] = None
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str
|
||||||
|
confirm_password: str
|
||||||
|
|
||||||
|
class PasswordReset(BaseModel):
|
||||||
|
token: str
|
||||||
|
new_password: str
|
||||||
|
confirm_password: str
|
||||||
|
|
||||||
|
# Team schemas
|
||||||
|
class TeamCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
class TeamOut(BaseModel):
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
class TeamMembershipCreate(BaseModel):
|
||||||
|
user_id: int
|
||||||
|
team_id: int
|
||||||
|
is_admin: bool = False
|
||||||
|
|
||||||
|
# Validator to handle form data conversion
|
||||||
|
@field_validator('is_admin')
|
||||||
|
def parse_boolean(cls, v):
|
||||||
|
if isinstance(v, str):
|
||||||
|
return v.lower() in ('true', 'yes', 'y', '1', 'on', 'checked')
|
||||||
|
return v
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from passlib.context import CryptContext
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
# Password hashing
|
||||||
|
# Use a simpler CryptContext configuration to avoid bcrypt.__about__ error
|
||||||
|
pwd_context = CryptContext(schemes=["bcrypt"])
|
||||||
|
|
||||||
|
# JWT settings
|
||||||
|
SECRET_KEY = "CHANGE_THIS_TO_A_STRONG_SECRET_KEY_IN_PRODUCTION"
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
||||||
|
|
||||||
|
def verify_password(plain_password, hashed_password):
|
||||||
|
"""Verify if the plain password matches the hashed one."""
|
||||||
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
|
|
||||||
|
def get_password_hash(password):
|
||||||
|
"""Hash a password for storing."""
|
||||||
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
def create_access_token(data: dict, expires_delta: timedelta = None):
|
||||||
|
"""Create JWT access token."""
|
||||||
|
to_encode = data.copy()
|
||||||
|
if expires_delta:
|
||||||
|
expire = datetime.utcnow() + expires_delta
|
||||||
|
else:
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode.update({"exp": expire})
|
||||||
|
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
def generate_token(length=32):
|
||||||
|
"""Generate a random token for email verification or password reset."""
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return ''.join(secrets.choice(alphabet) for i in range(length))
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mx-auto p-4">
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green mb-4">About LeagueLedger</h1>
|
||||||
|
<p class="mb-4">
|
||||||
|
LeagueLedger is your ultimate companion for tracking pub quiz team achievements. We aim to provide a fun and engaging platform for quiz enthusiasts to connect, compete, and celebrate their knowledge.
|
||||||
|
</p>
|
||||||
|
<p class="mb-4">
|
||||||
|
As a pub quiz master, you can generate printout QR codes for your top-ranking teams, distribute them, and let teams redeem them for points on our website.
|
||||||
|
</p>
|
||||||
|
<p class="mb-4">
|
||||||
|
As a pub quiz team member, you can redeem QR codes, create a team name, invite other members, and use social logins for easy access.
|
||||||
|
</p>
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Our Mission</h2>
|
||||||
|
<p class="mb-4">
|
||||||
|
To enhance the pub quiz experience by providing a seamless and intuitive platform for tracking team progress, fostering friendly competition, and celebrating the spirit of trivia.
|
||||||
|
</p>
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Our Team</h2>
|
||||||
|
<p class="mb-4">
|
||||||
|
LeagueLedger is an Open-Source initiative and part of the KaufDeinQuiz platform. It is brought to you by Christian Louis IT Beratung und Medienproduktion, a team of dedicated quiz enthusiasts and software developers passionate about creating innovative solutions for the pub quiz community.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green mb-6">
|
||||||
|
{% if is_new %}Create{% else %}Edit{% endif %} {{ display_name[:-1] if display_name.endswith('s') else display_name }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<!-- Form -->
|
||||||
|
<form method="post" action="/admin/{{ model_name }}{% if is_new %}/new{% else %}/{{ record.id }}{% endif %}" class="space-y-6">
|
||||||
|
{% for column_name, column_info in columns_info.items() %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
|
||||||
|
<label for="{{ column_name }}" class="block text-sm font-medium text-gray-700">
|
||||||
|
{{ column_name|replace('_', ' ')|title }}
|
||||||
|
{% if column_info.primary_key %}
|
||||||
|
<span class="ml-1 text-xs text-irish-green">(Primary Key)</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if column_info.foreign_key %}
|
||||||
|
<span class="ml-1 text-xs text-blue-600">(Foreign Key)</span>
|
||||||
|
{% endif %}
|
||||||
|
</label>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
{% if column_info.primary_key and is_new %}
|
||||||
|
<!-- For new records, primary key is often auto-generated -->
|
||||||
|
<input type="text" id="{{ column_name }}" name="{{ column_name }}" placeholder="Auto-generated"
|
||||||
|
class="bg-gray-100 border border-gray-300 text-gray-500 rounded-md px-3 py-2 w-full"
|
||||||
|
{% if not column_info.nullable %}disabled{% endif %}>
|
||||||
|
|
||||||
|
{% elif column_info.foreign_key and column_name in foreign_key_options %}
|
||||||
|
<!-- Foreign key dropdown -->
|
||||||
|
<select id="{{ column_name }}" name="{{ column_name }}"
|
||||||
|
class="border border-gray-300 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||||
|
{% if not column_info.nullable %}required{% endif %}>
|
||||||
|
|
||||||
|
{% if column_info.nullable %}
|
||||||
|
<option value="">-- None --</option>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for value, label in foreign_key_options[column_name] %}
|
||||||
|
<option value="{{ value }}" {% if record and record[column_name] == value %}selected{% endif %}>
|
||||||
|
{{ label }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{% elif column_info.type.startswith('BOOLEAN') %}
|
||||||
|
<!-- Boolean field -->
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="checkbox" id="{{ column_name }}" name="{{ column_name }}" class="h-5 w-5"
|
||||||
|
value="True" {% if record and record[column_name] %}checked{% endif %}>
|
||||||
|
<span class="ml-2 text-sm text-gray-600">Yes</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif 'text' in column_info.type.lower() %}
|
||||||
|
<!-- Text area for longer text -->
|
||||||
|
<textarea id="{{ column_name }}" name="{{ column_name }}" rows="4"
|
||||||
|
class="border border-gray-300 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||||
|
{% if not column_info.nullable %}required{% endif %}>{{ record[column_name] if record else '' }}</textarea>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- Standard input field -->
|
||||||
|
<input type="{{ 'number' if 'int' in column_info.type.lower() else 'text' }}"
|
||||||
|
id="{{ column_name }}" name="{{ column_name }}"
|
||||||
|
value="{{ record[column_name] if record and record[column_name] is not none else '' }}"
|
||||||
|
class="border border-gray-300 rounded-md px-3 py-2 w-full focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||||
|
{% if not column_info.nullable and not column_info.primary_key %}required{% endif %}>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if column_info.nullable %}
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Optional field</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Form buttons -->
|
||||||
|
<div class="flex justify-end space-x-4 pt-6 border-t">
|
||||||
|
<a href="/admin/{{ model_name }}" class="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50 transition">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit" class="px-4 py-2 bg-irish-green text-white rounded-md hover:bg-opacity-90 transition">
|
||||||
|
{% if is_new %}Create{% else %}Update{% endif %}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back links -->
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="/admin/{{ model_name }}" class="text-irish-green hover:underline mr-4">
|
||||||
|
<i class="fas fa-list mr-1"></i> Back to {{ display_name }}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/" class="text-irish-green hover:underline">
|
||||||
|
<i class="fas fa-tachometer-alt mr-1"></i> Back to Admin Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green mb-6">Admin Dashboard</h1>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{% for model_key, display_name in models %}
|
||||||
|
<div class="bg-cream-white rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow">
|
||||||
|
<h3 class="text-xl font-bold text-irish-green mb-3">{{ display_name }}</h3>
|
||||||
|
<div class="flex items-center justify-between mt-4">
|
||||||
|
<a href="/admin/{{ model_key }}" class="bg-irish-green text-white px-4 py-2 rounded-md hover:bg-opacity-90 transition">
|
||||||
|
Manage
|
||||||
|
</a>
|
||||||
|
<a href="/admin/{{ model_key }}/new" class="text-irish-green hover:underline">
|
||||||
|
<i class="fas fa-plus"></i> Add New
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 pt-6 border-t border-gray-200">
|
||||||
|
<h2 class="text-xl font-bold text-irish-green mb-4">Quick Actions</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<a href="/qr/generate/10" class="bg-white border border-irish-green text-irish-green hover:bg-irish-green hover:text-white px-4 py-3 rounded-md transition flex items-center">
|
||||||
|
<i class="fas fa-qrcode mr-2"></i> Generate QR Code (10 points)
|
||||||
|
</a>
|
||||||
|
<a href="/teams/" class="bg-white border border-irish-green text-irish-green hover:bg-irish-green hover:text-white px-4 py-3 rounded-md transition flex items-center">
|
||||||
|
<i class="fas fa-users mr-2"></i> View Teams
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green">{{ display_name }}</h1>
|
||||||
|
<p class="text-gray-600">Manage your {{ display_name.lower() }} data</p>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 md:mt-0">
|
||||||
|
<a href="/admin/{{ model_name }}/new" class="bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
|
||||||
|
<i class="fas fa-plus mr-1"></i> Create New
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Records Table -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr class="bg-irish-green text-white">
|
||||||
|
<th class="p-3 text-left">ID</th>
|
||||||
|
{% for column in columns %}
|
||||||
|
{% if column != 'id' %}
|
||||||
|
<th class="p-3 text-left">{{ column|replace('_', ' ')|title }}</th>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<th class="p-3 text-center">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
{% for record in records %}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="p-3 font-medium">{{ record.id }}</td>
|
||||||
|
{% for column in columns %}
|
||||||
|
{% if column != 'id' %}
|
||||||
|
<td class="p-3">
|
||||||
|
{% if columns_info[column].foreign_key %}
|
||||||
|
<span class="bg-gray-100 text-gray-800 px-2 py-1 rounded text-xs">FK: {{ record[column] }}</span>
|
||||||
|
{% elif record[column] is none %}
|
||||||
|
<span class="text-gray-400 italic">NULL</span>
|
||||||
|
{% elif columns_info[column].type.startswith('BOOLEAN') %}
|
||||||
|
{% if record[column] %}
|
||||||
|
<span class="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs">Yes</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="bg-red-100 text-red-800 px-2 py-1 rounded-full text-xs">No</span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ record[column]|string|truncate(50) }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<td class="p-3 text-center">
|
||||||
|
<div class="flex justify-center space-x-2">
|
||||||
|
<a href="/admin/{{ model_name }}/{{ record.id }}" class="text-irish-green hover:text-opacity-70" title="Edit">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/{{ model_name }}/{{ record.id }}/delete" class="text-guinness-red hover:text-opacity-70" title="Delete"
|
||||||
|
onclick="return confirm('Are you sure you want to delete this record?');">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if not records %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="{{ columns|length + 1 }}" class="p-4 text-center text-gray-500">
|
||||||
|
No records found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<div class="mt-6 flex justify-between items-center">
|
||||||
|
<div class="text-gray-600 text-sm">
|
||||||
|
Showing {{ (page - 1) * per_page + 1 }}-{{ [page * per_page, total_records]|min }} of {{ total_records }} records
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-1">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a href="?page={{ page - 1 }}&per_page={{ per_page }}" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded">
|
||||||
|
« Prev
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for p in range(1, total_pages + 1) %}
|
||||||
|
{% if p == page %}
|
||||||
|
<span class="px-3 py-1 bg-irish-green text-white rounded">{{ p }}</span>
|
||||||
|
{% elif p <= 3 or p >= total_pages - 2 or (p >= page - 1 and p <= page + 1) %}
|
||||||
|
<a href="?page={{ p }}&per_page={{ per_page }}" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded">
|
||||||
|
{{ p }}
|
||||||
|
</a>
|
||||||
|
{% elif p == 4 and page > 5 or p == total_pages - 3 and page < total_pages - 4 %}
|
||||||
|
<span class="px-3 py-1">...</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a href="?page={{ page + 1 }}&per_page={{ per_page }}" class="px-3 py-1 bg-gray-100 hover:bg-gray-200 rounded">
|
||||||
|
Next »
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back to Admin -->
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="/admin/" class="text-irish-green hover:underline">
|
||||||
|
<i class="fas fa-arrow-left mr-1"></i> Back to Admin Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto my-8">
|
||||||
|
<div class="bg-white p-8 rounded-lg shadow-md">
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green mb-6 text-center">Log In</h1>
|
||||||
|
|
||||||
|
<!-- Messages/Alerts -->
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="mb-4 p-3 rounded {% if message.type == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
|
||||||
|
{{ message.text }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form action="/auth/login" method="post" class="space-y-4">
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username or Email</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value="{{ username or '' }}"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Debug info -->
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<p>Having trouble logging in? Make sure your browser accepts cookies.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="remember"
|
||||||
|
name="remember"
|
||||||
|
class="h-4 w-4 text-irish-green focus:ring-irish-green border-gray-300 rounded"
|
||||||
|
>
|
||||||
|
<label for="remember" class="ml-2 block text-sm text-gray-700">Remember me</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-irish-green"
|
||||||
|
>
|
||||||
|
Log In
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center text-sm mt-4">
|
||||||
|
<a href="/auth/forgot-password" class="text-irish-green hover:text-opacity-80">Forgot password?</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200 text-center">
|
||||||
|
<p class="text-gray-600">Don't have an account?</p>
|
||||||
|
<a href="/auth/register" class="block mt-2 bg-cream-white text-irish-green border border-irish-green py-2 px-4 rounded-md hover:bg-irish-green hover:text-white transition">
|
||||||
|
Create an account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- OAuth login options (to be implemented later) -->
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200">
|
||||||
|
<p class="text-center text-gray-600 mb-4">Or sign in with</p>
|
||||||
|
<div class="flex justify-center space-x-4">
|
||||||
|
<button class="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full disabled:opacity-50" disabled>
|
||||||
|
<i class="fab fa-google mr-2"></i> Google
|
||||||
|
</button>
|
||||||
|
<button class="bg-gray-800 text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition w-full disabled:opacity-50" disabled>
|
||||||
|
<i class="fab fa-github mr-2"></i> GitHub
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-center text-gray-500 text-xs mt-2">OAuth login coming soon</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
|
||||||
|
<!-- Profile Header -->
|
||||||
|
<div class="bg-irish-green text-white p-6">
|
||||||
|
<div class="flex flex-col sm:flex-row items-center">
|
||||||
|
<div class="w-24 h-24 rounded-full bg-white border-4 border-white overflow-hidden mb-4 sm:mb-0 sm:mr-6">
|
||||||
|
<img src="https://via.placeholder.com/150" alt="Profile" class="w-full h-full object-cover">
|
||||||
|
</div>
|
||||||
|
<div class="text-center sm:text-left">
|
||||||
|
<h1 class="text-2xl font-bold">{{ user.username }}</h1>
|
||||||
|
<p class="text-green-100">Member since {{ user.created_at.strftime('%B %Y') }}</p>
|
||||||
|
<p class="mt-2">
|
||||||
|
{% if user.is_verified %}
|
||||||
|
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full font-medium mr-1">Verified</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="bg-white text-irish-green text-xs px-2 py-1 rounded-full font-medium">{{ user.memberships|length }} Teams</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages/Alerts -->
|
||||||
|
{% if messages %}
|
||||||
|
<div class="p-4">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="mb-4 p-3 rounded {% if message.type == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
|
||||||
|
{{ message.text }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Profile Content -->
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- Left Column: Personal Info -->
|
||||||
|
<div class="md:col-span-1">
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mb-4">Personal Information</h2>
|
||||||
|
|
||||||
|
<form action="/auth/update-profile" method="post" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-600">Display Name</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="username"
|
||||||
|
value="{{ user.username }}"
|
||||||
|
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-600">Email</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
name="email"
|
||||||
|
value="{{ user.email }}"
|
||||||
|
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<button type="submit" class="w-full bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
|
||||||
|
Update Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 pt-6 border-t">
|
||||||
|
<h3 class="text-lg font-medium text-irish-green mb-3">Change Password</h3>
|
||||||
|
<form action="/auth/change-password" method="post" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-600">Current Password</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="current_password"
|
||||||
|
required
|
||||||
|
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-600">New Password</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="new_password"
|
||||||
|
required
|
||||||
|
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-600">Confirm New Password</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
name="confirm_password"
|
||||||
|
required
|
||||||
|
class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full bg-golden-ale hover:bg-opacity-90 text-black-stout font-medium py-2 px-4 rounded-md transition">
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Stats & Teams -->
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Statistics</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="bg-cream-white p-4 rounded-lg text-center">
|
||||||
|
<p class="text-sm text-gray-600">Total Points</p>
|
||||||
|
<p class="text-2xl font-bold text-irish-green">378</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-cream-white p-4 rounded-lg text-center">
|
||||||
|
<p class="text-sm text-gray-600">QR Codes Redeemed</p>
|
||||||
|
<p class="text-2xl font-bold text-irish-green">24</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-cream-white p-4 rounded-lg text-center">
|
||||||
|
<p class="text-sm text-gray-600">Teams Joined</p>
|
||||||
|
<p class="text-2xl font-bold text-irish-green">{{ user.memberships|length }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-cream-white p-4 rounded-lg text-center">
|
||||||
|
<p class="text-sm text-gray-600">Best Position</p>
|
||||||
|
<p class="text-2xl font-bold text-irish-green">#2</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Teams</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for team_info in user_teams %}
|
||||||
|
<div class="border rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-cream-white px-4 py-3 flex justify-between items-center">
|
||||||
|
<div class="font-medium">{{ team_info.team.name }}</div>
|
||||||
|
{% if team_info.is_admin %}
|
||||||
|
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full">Captain</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="bg-gray-200 text-gray-700 text-xs px-2 py-1 rounded-full">Member</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm">Current Points: <span class="font-bold">187</span></p>
|
||||||
|
<p class="text-sm text-gray-600">Current Rank: #4</p>
|
||||||
|
</div>
|
||||||
|
<a href="/teams/{{ team_info.team.id }}" class="text-irish-green hover:underline text-sm">View Team</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p class="text-gray-600">You haven't joined any teams yet.</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<a href="/teams/" class="block text-center text-irish-green hover:underline text-sm">
|
||||||
|
<i class="fas fa-plus mr-1"></i> Join or Create Another Team
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Management -->
|
||||||
|
<div class="mt-8 pt-6 border-t">
|
||||||
|
<h3 class="text-xl text-red-600 font-semibold mb-4">Danger Zone</h3>
|
||||||
|
<p class="text-gray-700 mb-4">These actions cannot be undone. Please be certain.</p>
|
||||||
|
|
||||||
|
<a href="/auth/delete-account" class="inline-block bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md">
|
||||||
|
Delete Account
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto my-8">
|
||||||
|
<div class="bg-white p-8 rounded-lg shadow-md">
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green mb-6 text-center">Create an Account</h1>
|
||||||
|
|
||||||
|
<!-- Messages/Alerts -->
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="mb-4 p-3 rounded {% if message.type == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
|
||||||
|
{{ message.text }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form action="/auth/register" method="post" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">Username</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
value="{{ username or '' }}"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Choose a unique username (3-30 characters, letters, numbers and underscores only)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
value="{{ email or '' }}"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">At least 8 characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="confirm_password" class="block text-sm font-medium text-gray-700 mb-1">Confirm Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirm_password"
|
||||||
|
name="confirm_password"
|
||||||
|
required
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-irish-green focus:border-irish-green"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="terms"
|
||||||
|
name="terms"
|
||||||
|
required
|
||||||
|
class="h-4 w-4 mt-1 text-irish-green focus:ring-irish-green border-gray-300 rounded"
|
||||||
|
>
|
||||||
|
<label for="terms" class="ml-2 block text-sm text-gray-700">
|
||||||
|
I agree to the <a href="/terms" class="text-irish-green hover:underline" target="_blank">Terms of Service</a> and <a href="/privacy" class="text-irish-green hover:underline" target="_blank">Privacy Policy</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="w-full bg-irish-green text-white py-2 px-4 rounded-md hover:bg-opacity-90 transition focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-irish-green"
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 pt-6 border-t border-gray-200 text-center">
|
||||||
|
<p class="text-gray-600">Already have an account?</p>
|
||||||
|
<a href="/auth/login" class="text-irish-green hover:underline">Log in</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-md mx-auto my-12">
|
||||||
|
<div class="bg-white p-8 rounded-lg shadow-md text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="inline-block p-4 rounded-full bg-green-100">
|
||||||
|
<i class="fas fa-check-circle text-irish-green text-5xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green mb-4">Registration Successful!</h1>
|
||||||
|
<p class="text-gray-600 mb-6">Your account has been created and you're now logged in.</p>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<a href="/dashboard" class="block w-full bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
|
||||||
|
Go to Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/teams/" class="block w-full bg-cream-white border border-irish-green text-irish-green hover:bg-irish-green hover:text-white font-medium py-2 px-4 rounded-md transition">
|
||||||
|
Join a Team
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 pt-6 border-t border-gray-200">
|
||||||
|
<h2 class="font-semibold mb-2">What's Next?</h2>
|
||||||
|
<ul class="text-left text-sm space-y-2">
|
||||||
|
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Explore the pub quiz leaderboards</li>
|
||||||
|
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Join an existing team or create your own</li>
|
||||||
|
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Scan QR codes at quiz events to earn points</li>
|
||||||
|
<li><i class="fas fa-check-circle text-irish-green mr-2"></i> Complete your profile information</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>LeagueLedger</title>
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<!-- Font Awesome Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'irish-green': '#006837',
|
||||||
|
'golden-ale': '#FFB400',
|
||||||
|
'cream-white': '#F5F0E1',
|
||||||
|
'black-stout': '#1A1A1A',
|
||||||
|
'guinness-red': '#B22222',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
'garamond': ['"EB Garamond"', 'serif'],
|
||||||
|
'inter': ['"Inter"', 'sans-serif'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
}
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-family: 'EB Garamond', serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="flex flex-col min-h-screen bg-cream-white text-black-stout">
|
||||||
|
<header class="bg-irish-green text-cream-white shadow-md">
|
||||||
|
<div class="container mx-auto px-4">
|
||||||
|
<!-- Desktop Navigation -->
|
||||||
|
<div class="flex justify-between items-center py-4">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<img src="https://via.placeholder.com/40x40" alt="LeagueLedger Logo" class="h-10 w-10">
|
||||||
|
<h1 class="text-2xl font-bold font-garamond">LeagueLedger</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop Navigation Links -->
|
||||||
|
<nav class="hidden md:flex items-center space-x-6">
|
||||||
|
<a href="/" class="hover:text-golden-ale transition-colors duration-200">
|
||||||
|
<i class="fas fa-home"></i> Home
|
||||||
|
</a>
|
||||||
|
<a href="/teams/" class="hover:text-golden-ale transition-colors duration-200">
|
||||||
|
<i class="fas fa-users"></i> Teams
|
||||||
|
</a>
|
||||||
|
<a href="/leaderboard" class="hover:text-golden-ale transition-colors duration-200">
|
||||||
|
<i class="fas fa-trophy"></i> Leaderboard
|
||||||
|
</a>
|
||||||
|
<a href="/dashboard" class="hover:text-golden-ale transition-colors duration-200">
|
||||||
|
<i class="fas fa-tachometer-alt"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/about" class="hover:text-golden-ale transition-colors duration-200">
|
||||||
|
About
|
||||||
|
</a>
|
||||||
|
<a href="/contact" class="hover:text-golden-ale transition-colors duration-200">
|
||||||
|
Contact
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- User Authentication -->
|
||||||
|
{% if current_user %}
|
||||||
|
<div class="relative group">
|
||||||
|
<button class="flex items-center focus:outline-none">
|
||||||
|
<span class="mr-1">{{ current_user.username }}</span>
|
||||||
|
<i class="fas fa-chevron-down text-xs"></i>
|
||||||
|
</button>
|
||||||
|
<div class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg overflow-hidden z-20 hidden group-hover:block">
|
||||||
|
<a href="/auth/profile" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Profile</a>
|
||||||
|
<a href="/dashboard/" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Dashboard</a>
|
||||||
|
<div class="border-t border-gray-100"></div>
|
||||||
|
<a href="/auth/logout" class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Logout</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a href="/auth/login" class="bg-white text-irish-green px-4 py-1 rounded-md hover:bg-cream-white transition-colors duration-200">
|
||||||
|
Log In
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile Menu Button -->
|
||||||
|
<button id="mobile-menu-button" class="md:hidden text-cream-white focus:outline-none">
|
||||||
|
<i class="fas fa-bars text-2xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Navigation Menu -->
|
||||||
|
<div id="mobile-menu" class="md:hidden hidden pb-4">
|
||||||
|
<nav class="flex flex-col space-y-3">
|
||||||
|
<a href="/" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||||
|
<i class="fas fa-home"></i> Home
|
||||||
|
</a>
|
||||||
|
<a href="/teams/" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||||
|
<i class="fas fa-users"></i> Teams
|
||||||
|
</a>
|
||||||
|
<a href="/leaderboard" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||||
|
<i class="fas fa-trophy"></i> Leaderboard
|
||||||
|
</a>
|
||||||
|
<a href="/dashboard" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||||
|
<i class="fas fa-tachometer-alt"></i> Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/about" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||||
|
About
|
||||||
|
</a>
|
||||||
|
<a href="/contact" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||||
|
Contact
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Mobile Auth Links -->
|
||||||
|
{% if current_user %}
|
||||||
|
<a href="/auth/profile" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||||
|
<i class="fas fa-user"></i> {{ current_user.username }}
|
||||||
|
</a>
|
||||||
|
<a href="/auth/logout" class="hover:bg-green-800 py-2 px-3 rounded-md transition-colors duration-200">
|
||||||
|
<i class="fas fa-sign-out-alt"></i> Logout
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/auth/login" class="bg-white text-irish-green py-2 px-3 rounded-md">
|
||||||
|
Log In
|
||||||
|
</a>
|
||||||
|
<a href="/auth/register" class="border border-white text-white py-2 px-3 rounded-md">
|
||||||
|
Sign Up
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-grow container mx-auto px-4 py-6">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-black-stout text-cream-white mt-auto">
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||||
|
<div class="mb-4 md:mb-0">
|
||||||
|
<h3 class="text-xl font-garamond">LeagueLedger</h3>
|
||||||
|
<p class="text-sm text-cream-white opacity-70">Track Your Triumphs. Celebrate the Quiz.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="/about" class="text-cream-white hover:text-golden-ale transition-colors duration-200">About</a>
|
||||||
|
<a href="/contact" class="text-cream-white hover:text-golden-ale transition-colors duration-200">Contact</a>
|
||||||
|
<a href="/privacy" class="text-cream-white hover:text-golden-ale transition-colors duration-200">Privacy</a>
|
||||||
|
<a href="/terms" class="text-cream-white hover:text-golden-ale transition-colors duration-200">Terms</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-gray-700 mt-6 pt-6 text-center text-sm text-cream-white opacity-70">
|
||||||
|
© {{ now().year }} LeagueLedger. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Mobile menu toggle
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const menuButton = document.getElementById('mobile-menu-button');
|
||||||
|
const mobileMenu = document.getElementById('mobile-menu');
|
||||||
|
|
||||||
|
menuButton.addEventListener('click', function() {
|
||||||
|
mobileMenu.classList.toggle('hidden');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mx-auto p-4">
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green mb-4">Contact Us</h1>
|
||||||
|
<p class="mb-4">
|
||||||
|
Have questions, suggestions, or feedback? We'd love to hear from you!
|
||||||
|
</p>
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Contact Information</h2>
|
||||||
|
<p><strong>Christian Louis IT Beratung</strong></p>
|
||||||
|
<p>Alter Steinweg 3</p>
|
||||||
|
<p>20459 Hamburg</p>
|
||||||
|
<p>Deutschland</p>
|
||||||
|
<p><strong>Phone:</strong> +49 179 5183732</p>
|
||||||
|
<p><strong>Email:</strong> <a href="mailto:quizarium@kaufdeinquiz.com">quizarium@kaufdeinquiz.com</a></p>
|
||||||
|
<p><strong>Fax:</strong> +49 40 97074609</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Connect With Us</h2>
|
||||||
|
<p>This project is part of the KaufDeinQuiz platform and is operated as an Open-Source initiative. All rights reserved.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- User Welcome -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h1 class="text-2xl md:text-3xl font-garamond text-irish-green mb-2">Welcome, {{ user.username }}!</h1>
|
||||||
|
<p class="text-gray-600">Here's your quiz league status</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Overview -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-md text-center">
|
||||||
|
<i class="fas fa-users text-irish-green text-2xl mb-2"></i>
|
||||||
|
<h3 class="font-semibold text-lg mb-1">Your Teams</h3>
|
||||||
|
<p class="text-3xl font-bold">{{ team_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-md text-center">
|
||||||
|
<i class="fas fa-star text-golden-ale text-2xl mb-2"></i>
|
||||||
|
<h3 class="font-semibold text-lg mb-1">Total Points</h3>
|
||||||
|
<p class="text-3xl font-bold">{{ total_points }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-md text-center">
|
||||||
|
<i class="fas fa-trophy text-irish-green text-2xl mb-2"></i>
|
||||||
|
<h3 class="font-semibold text-lg mb-1">Best Ranking</h3>
|
||||||
|
<p class="text-3xl font-bold">{% if best_rank %}#{{ best_rank }}{% else %}-{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<h2 class="text-xl md:text-2xl font-garamond font-semibold text-irish-green mb-4">Recent Activity</h2>
|
||||||
|
{% if recent_activity %}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for activity in recent_activity %}
|
||||||
|
<div class="flex items-center border-b pb-3">
|
||||||
|
{% if activity.type == 'qr_redeem' %}
|
||||||
|
<div class="bg-green-100 p-2 rounded-full mr-3">
|
||||||
|
<i class="fas fa-qrcode text-irish-green"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">You redeemed a QR code for {{ activity.points }} points</p>
|
||||||
|
<p class="text-sm text-gray-600">Team: {{ activity.team_name }} • {{ activity.date }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 italic">No recent activity to show</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<h2 class="text-xl font-garamond font-semibold text-irish-green mb-4">Your Teams</h2>
|
||||||
|
{% if teams %}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for team in teams %}
|
||||||
|
<div class="flex justify-between items-center border-b pb-2">
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ team.name }}</p>
|
||||||
|
<p class="text-sm text-gray-600">{{ team_points[team.id].points }} points • Rank #{{ team_points[team.id].rank }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
{% if team.id in admin_team_ids %}
|
||||||
|
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full mr-3">Admin</span>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/teams/{{ team.id }}" class="text-irish-green hover:underline">View</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 italic">You haven't joined any teams yet</p>
|
||||||
|
{% endif %}
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="/teams/" class="inline-block bg-irish-green text-white px-4 py-2 rounded-md text-sm">
|
||||||
|
{% if teams %}Manage Teams{% else %}Join a Team{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<h2 class="text-xl font-garamond font-semibold text-irish-green mb-4">Redeem Code</h2>
|
||||||
|
<p class="mb-4">Have a QR code from your quiz master? Scan or enter it here to claim points!</p>
|
||||||
|
<div class="flex flex-col space-y-3">
|
||||||
|
<a href="/dashboard/scan" class="bg-irish-green text-white px-4 py-2 rounded-md flex items-center justify-center">
|
||||||
|
<i class="fas fa-camera mr-2"></i> Scan QR Code
|
||||||
|
</a>
|
||||||
|
<form action="/redeem/manual" method="post" class="flex">
|
||||||
|
<input type="text" name="code" placeholder="Or enter code manually"
|
||||||
|
class="flex-grow border border-gray-300 rounded-l-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green">
|
||||||
|
<button type="submit" class="bg-golden-ale text-black-stout px-4 py-2 rounded-r-md">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaderboard Preview -->
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-garamond font-semibold text-irish-green">Leaderboard Preview</h2>
|
||||||
|
<a href="/leaderboard" class="text-irish-green hover:underline">View Full Leaderboard</a>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-irish-green text-white">
|
||||||
|
<tr>
|
||||||
|
<th class="py-2 px-4 text-left rounded-tl-lg">Rank</th>
|
||||||
|
<th class="py-2 px-4 text-left">Team</th>
|
||||||
|
<th class="py-2 px-4 text-right rounded-tr-lg">Points</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
<!-- This would be populated with real data from the backend -->
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 px-4 font-medium">1</td>
|
||||||
|
<td class="py-2 px-4">Quiz Masters</td>
|
||||||
|
<td class="py-2 px-4 text-right font-medium">287</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 px-4 font-medium">2</td>
|
||||||
|
<td class="py-2 px-4">Trivia Titans</td>
|
||||||
|
<td class="py-2 px-4 text-right font-medium">215</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-2 px-4 font-medium">3</td>
|
||||||
|
<td class="py-2 px-4">Beer Brainiacs</td>
|
||||||
|
<td class="py-2 px-4 text-right font-medium">194</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto mt-8 p-8 bg-white rounded-lg shadow-md">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="fas fa-exclamation-circle text-red-600 text-5xl mb-4"></i>
|
||||||
|
<h1 class="text-2xl font-bold mb-4">{{ error_title|default("Error") }}</h1>
|
||||||
|
<p class="text-gray-700 mb-6">{{ error_message|default("An error occurred. Please try again.") }}</p>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<a href="/" class="text-irish-green hover:underline mr-6">
|
||||||
|
<i class="fas fa-home mr-1"></i> Go to Home
|
||||||
|
</a>
|
||||||
|
<a href="javascript:history.back()" class="text-irish-green hover:underline">
|
||||||
|
<i class="fas fa-arrow-left mr-1"></i> Go Back
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="w-full py-8 md:py-16 text-center">
|
||||||
|
<h1 class="text-4xl md:text-6xl font-bold text-irish-green mb-4">PubQuiz League Tracker</h1>
|
||||||
|
<p class="text-xl md:text-2xl mb-8 max-w-3xl mx-auto">Track your team's triumphs across quiz nights and climb the leaderboard.</p>
|
||||||
|
<div class="flex flex-col sm:flex-row justify-center gap-4">
|
||||||
|
<a href="/teams/" class="bg-irish-green text-white font-semibold px-6 py-3 rounded-md shadow-md hover:bg-opacity-90 transition">Join a Team</a>
|
||||||
|
<a href="/dashboard" class="bg-golden-ale text-black-stout font-semibold px-6 py-3 rounded-md shadow-md hover:bg-opacity-90 transition">My Dashboard</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<section class="w-full py-12 bg-white rounded-lg shadow-md my-8">
|
||||||
|
<div class="container mx-auto">
|
||||||
|
<h2 class="text-3xl font-bold text-irish-green text-center mb-10">How It Works</h2>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 px-4">
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
<div class="bg-cream-white p-4 rounded-full mb-4">
|
||||||
|
<i class="fas fa-users text-irish-green text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-2">Create or Join Teams</h3>
|
||||||
|
<p>Join existing quiz teams or create your own to start competing.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
<div class="bg-cream-white p-4 rounded-full mb-4">
|
||||||
|
<i class="fas fa-qrcode text-irish-green text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-2">Scan QR Codes</h3>
|
||||||
|
<p>Earn points by scanning QR codes distributed by quiz masters.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
<div class="bg-cream-white p-4 rounded-full mb-4">
|
||||||
|
<i class="fas fa-trophy text-irish-green text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-semibold mb-2">Climb the Leaderboard</h3>
|
||||||
|
<p>Track your progress and compete to be the top team in the league.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Call to Action -->
|
||||||
|
<section class="w-full py-12 bg-irish-green rounded-lg shadow-md my-8 text-center">
|
||||||
|
<h2 class="text-3xl font-bold text-white mb-4">Ready to Join the League?</h2>
|
||||||
|
<p class="text-white text-xl mb-6">Get started now and track your pub quiz achievements!</p>
|
||||||
|
<a href="/register" class="bg-golden-ale text-black-stout font-semibold px-8 py-3 rounded-md shadow-md hover:bg-opacity-90 transition">Sign Up Now</a>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl md:text-3xl font-garamond font-bold text-irish-green">League Leaderboard</h1>
|
||||||
|
<p class="text-gray-600">See how your team ranks against the competition</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 md:mt-0">
|
||||||
|
<form method="get" action="/leaderboard/">
|
||||||
|
<select name="timeframe"
|
||||||
|
onchange="this.form.submit()"
|
||||||
|
class="border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green bg-white">
|
||||||
|
<option value="all" {% if timeframe == "all" %}selected{% endif %}>All Time</option>
|
||||||
|
<option value="month" {% if timeframe == "month" %}selected{% endif %}>This Month</option>
|
||||||
|
<option value="week" {% if timeframe == "week" %}selected{% endif %}>This Week</option>
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Podium (on larger screens) -->
|
||||||
|
<div class="hidden md:flex justify-center items-end space-x-8 mb-12">
|
||||||
|
<!-- 2nd Place -->
|
||||||
|
{% if top_teams[1] %}
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="w-20 h-20 rounded-full bg-white shadow-md flex items-center justify-center mb-4">
|
||||||
|
<span class="text-4xl font-bold text-gray-500">2</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-100 w-32 h-32 flex flex-col items-center justify-center rounded-t-lg">
|
||||||
|
<p class="font-bold text-lg">{{ top_teams[1].name }}</p>
|
||||||
|
<p class="text-irish-green font-bold">{{ top_teams[1].points }} pts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 1st Place -->
|
||||||
|
{% if top_teams[0] %}
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="w-24 h-24 rounded-full bg-golden-ale shadow-md flex items-center justify-center mb-4">
|
||||||
|
<span class="text-5xl font-bold text-white">1</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-100 w-36 h-40 flex flex-col items-center justify-center rounded-t-lg shadow-md">
|
||||||
|
<p class="font-bold text-xl">{{ top_teams[0].name }}</p>
|
||||||
|
<p class="text-irish-green font-bold text-xl">{{ top_teams[0].points }} pts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- 3rd Place -->
|
||||||
|
{% if top_teams[2] %}
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="w-16 h-16 rounded-full bg-[#CD7F32] shadow-md flex items-center justify-center mb-4">
|
||||||
|
<span class="text-3xl font-bold text-white">3</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-100 w-28 h-28 flex flex-col items-center justify-center rounded-t-lg">
|
||||||
|
<p class="font-bold">{{ top_teams[2].name }}</p>
|
||||||
|
<p class="text-irish-green font-bold">{{ top_teams[2].points }} pts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeframe Indicator -->
|
||||||
|
<div class="bg-irish-green bg-opacity-10 text-irish-green px-4 py-3 rounded-md text-center font-bold mb-6">
|
||||||
|
Showing {{ time_label }} Rankings
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Leaderboard Table -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<table class="min-w-full">
|
||||||
|
<thead class="bg-irish-green text-white">
|
||||||
|
<tr>
|
||||||
|
<th class="py-3 px-4 text-left">Rank</th>
|
||||||
|
<th class="py-3 px-4 text-left">Team</th>
|
||||||
|
<th class="py-3 px-4 text-right">Points</th>
|
||||||
|
<th class="py-3 px-4 text-center">Change</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200">
|
||||||
|
{% for team in teams %}
|
||||||
|
<tr class="{% if team.rank == 1 %}bg-golden-ale bg-opacity-10{% endif %} hover:bg-gray-50">
|
||||||
|
<td class="py-3 px-4 font-bold">{{ team.rank }}</td>
|
||||||
|
<td class="py-3 px-4">{{ team.name }}</td>
|
||||||
|
<td class="py-3 px-4 text-right font-bold">{{ team.points }}</td>
|
||||||
|
<td class="py-3 px-4 text-center">
|
||||||
|
{% if team.change > 0 %}
|
||||||
|
<span class="text-green-600"><i class="fas fa-arrow-up"></i> {{ team.change }}</span>
|
||||||
|
{% elif team.change < 0 %}
|
||||||
|
<span class="text-red-600"><i class="fas fa-arrow-down"></i> {{ team.change|abs }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if not teams %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="py-8 text-center text-gray-500">No teams found. Start a quiz league to see rankings here!</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Formation CTA -->
|
||||||
|
<div class="bg-irish-green rounded-lg shadow-md p-6 text-center text-white">
|
||||||
|
<h3 class="text-xl font-garamond font-bold mb-2">Ready to join the rankings?</h3>
|
||||||
|
<p class="mb-4">Create or join a team and start climbing the leaderboard today!</p>
|
||||||
|
<a href="/teams/" class="inline-block bg-white text-irish-green font-bold px-6 py-2 rounded-md hover:bg-gray-100 transition">
|
||||||
|
Join a Team
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mx-auto p-4">
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green mb-4">Privacy Policy</h1>
|
||||||
|
<p class="mb-4">
|
||||||
|
Your privacy is important to us. This Privacy Policy outlines how LeagueLedger collects, uses, and protects your information.
|
||||||
|
</p>
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Information We Collect</h2>
|
||||||
|
<ul class="list-disc ml-6 mb-4">
|
||||||
|
<li>Email addresses</li>
|
||||||
|
<li>Usernames</li>
|
||||||
|
<li>Team names</li>
|
||||||
|
<li>Quiz scores and points</li>
|
||||||
|
</ul>
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">How We Use Your Information</h2>
|
||||||
|
<p class="mb-4">
|
||||||
|
We use your information to:
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc ml-6 mb-4">
|
||||||
|
<li>Track leaderboard rankings</li>
|
||||||
|
<li>Send notifications about team activities</li>
|
||||||
|
<li>Improve our services and user experience</li>
|
||||||
|
</ul>
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Data Sharing</h2>
|
||||||
|
<p class="mb-4">
|
||||||
|
We do not share your personal information with third parties except as required by law.
|
||||||
|
</p>
|
||||||
|
<p class="mb-4">
|
||||||
|
Christian Louis IT Beratung und Medienproduktion, as the operator of this platform, takes data privacy seriously and implements measures to protect your personal information.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden mb-8">
|
||||||
|
<!-- Profile Header -->
|
||||||
|
<div class="bg-irish-green text-white p-6">
|
||||||
|
<div class="flex flex-col sm:flex-row items-center">
|
||||||
|
<div class="w-24 h-24 rounded-full bg-white border-4 border-white overflow-hidden mb-4 sm:mb-0 sm:mr-6">
|
||||||
|
<img src="https://via.placeholder.com/150" alt="Profile" class="w-full h-full object-cover">
|
||||||
|
</div>
|
||||||
|
<div class="text-center sm:text-left">
|
||||||
|
<h1 class="text-2xl font-bold">John Quizmaster</h1>
|
||||||
|
<p class="text-green-100">Member since October 2022</p>
|
||||||
|
<p class="mt-2">
|
||||||
|
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full font-medium mr-1">Quiz Master</span>
|
||||||
|
<span class="bg-white text-irish-green text-xs px-2 py-1 rounded-full font-medium">Team Captain</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Profile Content -->
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- Left Column: Personal Info -->
|
||||||
|
<div class="md:col-span-1">
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mb-4">Personal Information</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-600">Display Name</label>
|
||||||
|
<div class="mt-1 flex">
|
||||||
|
<input type="text" value="John Quizmaster" readonly class="flex-grow bg-gray-100 border border-gray-300 rounded-l-md px-3 py-2">
|
||||||
|
<button class="bg-irish-green text-white px-3 py-2 rounded-r-md">
|
||||||
|
<i class="fas fa-pencil-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-600">Email</label>
|
||||||
|
<div class="mt-1 flex">
|
||||||
|
<input type="email" value="john@example.com" readonly class="flex-grow bg-gray-100 border border-gray-300 rounded-l-md px-3 py-2">
|
||||||
|
<button class="bg-irish-green text-white px-3 py-2 rounded-r-md">
|
||||||
|
<i class="fas fa-pencil-alt"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-600">Password</label>
|
||||||
|
<div class="mt-1">
|
||||||
|
<button class="w-full bg-golden-ale hover:bg-opacity-90 text-black font-medium py-2 px-4 rounded-md transition">
|
||||||
|
Change Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4">
|
||||||
|
<button class="w-full bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Stats & Teams -->
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Statistics</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div class="bg-cream-white p-4 rounded-lg text-center">
|
||||||
|
<p class="text-sm text-gray-600">Total Points</p>
|
||||||
|
<p class="text-2xl font-bold text-irish-green">378</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-cream-white p-4 rounded-lg text-center">
|
||||||
|
<p class="text-sm text-gray-600">QR Codes Redeemed</p>
|
||||||
|
<p class="text-2xl font-bold text-irish-green">24</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-cream-white p-4 rounded-lg text-center">
|
||||||
|
<p class="text-sm text-gray-600">Teams Joined</p>
|
||||||
|
<p class="text-2xl font-bold text-irish-green">3</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-cream-white p-4 rounded-lg text-center">
|
||||||
|
<p class="text-sm text-gray-600">Best Position</p>
|
||||||
|
<p class="text-2xl font-bold text-irish-green">#2</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mb-4">Your Teams</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="border rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-cream-white px-4 py-3 flex justify-between items-center">
|
||||||
|
<div class="font-medium">Quiz Wizards</div>
|
||||||
|
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full">Captain</span>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm">Current Points: <span class="font-bold">187</span></p>
|
||||||
|
<p class="text-sm text-gray-600">Current Rank: #4</p>
|
||||||
|
</div>
|
||||||
|
<a href="/teams/1" class="text-irish-green hover:underline text-sm">View Team</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border rounded-lg overflow-hidden">
|
||||||
|
<div class="bg-cream-white px-4 py-3 flex justify-between items-center">
|
||||||
|
<div class="font-medium">Trivia Titans</div>
|
||||||
|
<span class="bg-gray-200 text-gray-700 text-xs px-2 py-1 rounded-full">Member</span>
|
||||||
|
</div>
|
||||||
|
<div class="px-4 py-3 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm">Current Points: <span class="font-bold">215</span></p>
|
||||||
|
<p class="text-sm text-gray-600">Current Rank: #2</p>
|
||||||
|
</div>
|
||||||
|
<a href="/teams/2" class="text-irish-green hover:underline text-sm">View Team</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/teams/" class="block text-center text-irish-green hover:underline text-sm">
|
||||||
|
<i class="fas fa-plus mr-1"></i> Join or Create Another Team
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Danger Zone -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h3 class="text-xl font-semibold text-red-600 mb-4">Danger Zone</h3>
|
||||||
|
<p class="text-gray-600 mb-4">The following actions are irreversible. Please proceed with caution.</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4">
|
||||||
|
<button class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-md transition">
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
<button class="bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-md transition">
|
||||||
|
Leave All Teams
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-lg mx-auto">
|
||||||
|
<!-- QR Code Redemption Box -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 mb-8">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<i class="fas fa-qrcode text-irish-green text-5xl mb-4"></i>
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green">QR Code Redemption</h1>
|
||||||
|
<p class="text-gray-600">Collect your points from the quiz master!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8 p-4 bg-irish-green bg-opacity-10 rounded-lg border border-irish-green text-center">
|
||||||
|
<p class="font-bold text-irish-green mb-1">Congratulations!</p>
|
||||||
|
<p class="text-lg">You've earned <span class="font-bold">{{ ticket.points }}</span> points</p>
|
||||||
|
<p class="text-sm text-gray-600 mt-2">Code: {{ ticket.code }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/redeem/apply/{{ ticket.code }}" method="post">
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="team_id" class="block text-sm font-medium text-gray-700 mb-2">Select Team to Award Points:</label>
|
||||||
|
<select name="team_id" id="team_id" required class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green">
|
||||||
|
<option value="">-- Select a team --</option>
|
||||||
|
{% for team in user_teams %}
|
||||||
|
<option value="{{ team.id }}">{{ team.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="w-full bg-irish-green hover:bg-opacity-90 text-white font-bold py-3 px-4 rounded-md transition">
|
||||||
|
Redeem Points
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="/teams/" class="text-irish-green hover:underline text-sm">
|
||||||
|
<i class="fas fa-users mr-1"></i> Create a New Team
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- How It Works -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h3 class="text-xl font-semibold text-irish-green mb-4">How It Works</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
|
||||||
|
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">1</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Scan QR Code</p>
|
||||||
|
<p class="text-gray-600 text-sm">Scan the QR code provided by your quiz master.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
|
||||||
|
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">2</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Select Your Team</p>
|
||||||
|
<p class="text-gray-600 text-sm">Choose which team should receive these points.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
|
||||||
|
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">3</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Climb the Leaderboard</p>
|
||||||
|
<p class="text-gray-600 text-sm">Watch your team rise in the rankings!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-lg mx-auto">
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 mb-8 text-center">
|
||||||
|
<div class="mb-6">
|
||||||
|
<div class="inline-block p-4 rounded-full bg-green-100">
|
||||||
|
<i class="fas fa-check-circle text-irish-green text-5xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green mb-4">Success!</h1>
|
||||||
|
<p class="text-xl mb-2">You've earned <span class="font-bold">{{ points }}</span> points</p>
|
||||||
|
<p class="text-gray-600 mb-6">Points have been added to <span class="font-semibold">{{ team.name }}</span></p>
|
||||||
|
|
||||||
|
<div class="p-4 bg-irish-green bg-opacity-10 rounded-lg border border-irish-green text-left mb-6">
|
||||||
|
<h3 class="font-bold text-irish-green mb-2">What's next?</h3>
|
||||||
|
<ul class="list-disc ml-5 text-gray-700">
|
||||||
|
<li>Check your team's position on the <a href="/leaderboard" class="text-irish-green hover:underline">leaderboard</a></li>
|
||||||
|
<li>Scan another QR code to earn more points</li>
|
||||||
|
<li>Invite friends to your team</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row justify-center space-y-3 sm:space-y-0 sm:space-x-4">
|
||||||
|
<a href="/dashboard" class="bg-irish-green hover:bg-opacity-90 text-white font-medium py-2 px-4 rounded-md transition">
|
||||||
|
Go to Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/dashboard/scan" class="bg-cream-white border border-irish-green text-irish-green font-medium py-2 px-4 rounded-md hover:bg-irish-green hover:text-white transition">
|
||||||
|
Scan Another Code
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center text-gray-600">
|
||||||
|
<p>Share your achievement!</p>
|
||||||
|
<div class="flex justify-center space-x-4 mt-2">
|
||||||
|
<a href="#" class="text-blue-600 hover:text-opacity-80"><i class="fab fa-facebook fa-lg"></i></a>
|
||||||
|
<a href="#" class="text-blue-400 hover:text-opacity-80"><i class="fab fa-twitter fa-lg"></i></a>
|
||||||
|
<a href="#" class="text-green-600 hover:text-opacity-80"><i class="fab fa-whatsapp fa-lg"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-lg mx-auto space-y-6">
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6 text-center">
|
||||||
|
<h1 class="text-2xl font-garamond font-bold text-irish-green mb-3">Scan QR Code</h1>
|
||||||
|
<p class="text-gray-600 mb-6">Position the QR code from your quiz master in the camera view</p>
|
||||||
|
|
||||||
|
<!-- QR Scanner Container -->
|
||||||
|
<div class="relative bg-black rounded-lg overflow-hidden" style="height: 300px;">
|
||||||
|
<div id="qr-reader" class="w-full h-full"></div>
|
||||||
|
<div id="scanner-overlay" class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div class="text-white">
|
||||||
|
<i class="fas fa-camera text-4xl mb-2 opacity-70"></i>
|
||||||
|
<p>Camera loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-sm text-gray-500">
|
||||||
|
Make sure the QR code is well-lit and clearly visible
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Entry Fallback -->
|
||||||
|
<div class="mt-8 border-t pt-6">
|
||||||
|
<h3 class="font-bold mb-3">Or enter code manually</h3>
|
||||||
|
<form action="/redeem/manual" method="post" class="flex">
|
||||||
|
<input type="text" name="code" placeholder="Enter code here" required
|
||||||
|
class="flex-grow border border-gray-300 rounded-l-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-irish-green">
|
||||||
|
<button type="submit" class="bg-irish-green text-white px-4 py-2 rounded-r-md">Submit</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- How It Works -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h3 class="text-xl font-semibold font-garamond text-irish-green mb-4">How It Works</h3>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
|
||||||
|
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">1</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Scan QR Code</p>
|
||||||
|
<p class="text-gray-600 text-sm">Scan the QR code provided by your quiz master.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
|
||||||
|
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">2</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Select Your Team</p>
|
||||||
|
<p class="text-gray-600 text-sm">Choose which team should receive these points.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="bg-cream-white p-2 rounded-full mr-3 mt-1">
|
||||||
|
<span class="w-6 h-6 flex items-center justify-center text-irish-green font-bold">3</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Climb the Leaderboard</p>
|
||||||
|
<p class="text-gray-600 text-sm">Watch your team rise in the rankings!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Back Link -->
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/dashboard" class="text-irish-green hover:underline">
|
||||||
|
<i class="fas fa-arrow-left mr-1"></i> Back to Dashboard
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- HTML5 QR Code Scanner Library -->
|
||||||
|
<script src="https://unpkg.com/html5-qrcode"></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const html5QrCode = new Html5Qrcode("qr-reader");
|
||||||
|
const qrOverlay = document.getElementById('scanner-overlay');
|
||||||
|
|
||||||
|
// Config
|
||||||
|
const config = { fps: 10, qrbox: 250 };
|
||||||
|
|
||||||
|
// Success function
|
||||||
|
function onScanSuccess(decodedText, decodedResult) {
|
||||||
|
// Stop scanning
|
||||||
|
html5QrCode.stop();
|
||||||
|
|
||||||
|
// Show loading message
|
||||||
|
qrOverlay.innerHTML = '<div class="text-white text-center"><i class="fas fa-circle-notch fa-spin text-3xl mb-3"></i><p>Code detected!</p><p>Redirecting...</p></div>';
|
||||||
|
qrOverlay.style.backgroundColor = 'rgba(0, 104, 55, 0.8)'; // Irish green with opacity
|
||||||
|
|
||||||
|
// Redirect to redeem page
|
||||||
|
window.location.href = '/redeem/' + decodedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start scanner
|
||||||
|
html5QrCode.start(
|
||||||
|
{ facingMode: "environment" },
|
||||||
|
config,
|
||||||
|
onScanSuccess,
|
||||||
|
(errorMessage) => {
|
||||||
|
// Handle error if needed
|
||||||
|
console.log(errorMessage);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Scanner started successfully
|
||||||
|
qrOverlay.style.display = 'none';
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
// Update overlay with error message
|
||||||
|
qrOverlay.innerHTML = `
|
||||||
|
<div class="text-white text-center">
|
||||||
|
<i class="fas fa-exclamation-triangle text-4xl mb-3"></i>
|
||||||
|
<p>Camera access denied or not available</p>
|
||||||
|
<p class="text-sm mt-2">Please use manual entry below</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
qrOverlay.style.backgroundColor = 'rgba(178, 34, 34, 0.8)'; // Guinness red with opacity
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Team Header -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
|
<div class="bg-irish-green p-6">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-center">
|
||||||
|
<div class="mb-4 md:mb-0">
|
||||||
|
<h1 class="text-2xl md:text-3xl font-bold text-white">{{ team.name }}</h1>
|
||||||
|
<div class="flex items-center space-x-2 text-green-100">
|
||||||
|
<span><i class="fas fa-trophy mr-1"></i> Rank #{{ team_rank }}</span>
|
||||||
|
<span class="hidden md:inline">•</span>
|
||||||
|
<span><i class="fas fa-star mr-1"></i> {{ total_points }} Points</span>
|
||||||
|
<span class="hidden md:inline">•</span>
|
||||||
|
<span><i class="fas fa-users mr-1"></i> {{ team_members|length }} Members</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="bg-white text-irish-green font-medium py-2 px-4 rounded-md hover:bg-opacity-90">
|
||||||
|
<i class="fas fa-share-alt mr-1"></i> Share Team
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Stats -->
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 mb-1">POINTS THIS MONTH</h3>
|
||||||
|
<p class="text-2xl font-bold">{{ points_this_month }}</p>
|
||||||
|
<p class="text-sm {% if point_change_positive %}text-green-600{% else %}text-red-600{% endif %}">
|
||||||
|
<i class="fas {% if point_change_positive %}fa-arrow-up{% else %}fa-arrow-down{% endif %} mr-1"></i>
|
||||||
|
{{ point_change|abs }} from last month
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 mb-1">BEST PERFORMANCE</h3>
|
||||||
|
<p class="text-2xl font-bold">1st Place</p>
|
||||||
|
<p class="text-sm text-gray-600">August 12, 2023</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 mb-1">TEAM FOUNDED</h3>
|
||||||
|
<p class="text-2xl font-bold">{{ days_ago }} days ago</p>
|
||||||
|
<p class="text-sm text-gray-600">{{ founded_date }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Members & Performance -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Team Members -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green">Team Members</h2>
|
||||||
|
<button class="text-irish-green hover:text-opacity-80">
|
||||||
|
<i class="fas fa-user-plus"></i> Invite
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for member in team_members %}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gray-200 mr-3 overflow-hidden">
|
||||||
|
<img src="https://via.placeholder.com/40" alt="User" class="w-full h-full object-cover">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ member.user.username }}</p>
|
||||||
|
<p class="text-xs text-gray-500">Joined {{ member.joined }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if member.is_admin %}
|
||||||
|
<span class="bg-golden-ale text-black-stout text-xs px-2 py-1 rounded-full">Captain</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="bg-gray-200 text-gray-700 text-xs px-2 py-1 rounded-full">Member</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Performance Chart -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mb-4">Team Performance</h2>
|
||||||
|
|
||||||
|
<!-- Placeholder for a chart - in real app, use Chart.js or similar -->
|
||||||
|
<div class="bg-gray-100 p-4 rounded-lg h-64 flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<i class="fas fa-chart-line text-5xl text-irish-green mb-2"></i>
|
||||||
|
<p class="text-gray-500">Performance chart would appear here</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-3">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<p>Last quiz night</p>
|
||||||
|
<p class="font-medium">{{ performance.last_quiz }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<p>Average per quiz</p>
|
||||||
|
<p class="font-medium">{{ performance.average }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<p>Best streak</p>
|
||||||
|
<p class="font-medium">{{ performance.best_streak }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mb-4">Recent Activity</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for activity in activities %}
|
||||||
|
{% if activity.type == 'points' %}
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="bg-green-100 p-2 rounded-full mr-3">
|
||||||
|
<i class="fas fa-trophy text-irish-green"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Earned {{ activity.points }} points in "{{ activity.event }}"</p>
|
||||||
|
<p class="text-sm text-gray-600">{{ activity.date }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif activity.type == 'join' %}
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="bg-blue-100 p-2 rounded-full mr-3">
|
||||||
|
<i class="fas fa-user-plus text-blue-700"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ activity.user }} joined the team</p>
|
||||||
|
<p class="text-sm text-gray-600">{{ activity.date }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif activity.type == 'achievement' %}
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="bg-golden-ale bg-opacity-20 p-2 rounded-full mr-3">
|
||||||
|
<i class="fas fa-medal text-golden-ale"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">Achieved {{ activity.achievement }} in "{{ activity.event }}"</p>
|
||||||
|
<p class="text-sm text-gray-600">{{ activity.date }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="#" class="text-irish-green hover:underline">View Full History</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Team Management -->
|
||||||
|
<div class="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mb-4">Team Management</h2>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Team Name</label>
|
||||||
|
<div class="flex">
|
||||||
|
<input type="text" value="{{ team.name }}" class="flex-grow border border-gray-300 rounded-l-md px-3 py-2" {% if not is_user_admin %}disabled{% endif %}>
|
||||||
|
<button class="bg-irish-green text-white px-4 py-2 rounded-r-md" {% if not is_user_admin %}disabled{% endif %}>Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">Team Privacy</label>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input type="checkbox" id="public-team" name="is_public" class="mr-2"
|
||||||
|
{% if team.is_public %}checked{% endif %}
|
||||||
|
{% if not is_user_admin %}disabled{% endif %}>
|
||||||
|
<label for="public-team">Make team publicly joinable (no invitation needed)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4 border-t">
|
||||||
|
<button class="bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md">
|
||||||
|
Leave Team
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h2 class="text-2xl font-bold mb-6">Teams</h2>
|
||||||
|
<div class="grid md:grid-cols-2 gap-6">
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<h3 class="text-xl font-semibold mb-4" style="color: var(--irish-green);">Available Teams</h3>
|
||||||
|
{% if teams %}
|
||||||
|
<ul class="space-y-3">
|
||||||
|
{% for team in teams %}
|
||||||
|
<li class="border-b pb-2 flex justify-between items-center">
|
||||||
|
<span class="font-medium">{{ team.name }}</span>
|
||||||
|
<form action="/teams/join/{{ team.id }}" method="post" class="inline">
|
||||||
|
<button type="submit"
|
||||||
|
class="px-3 py-1 text-sm rounded-md"
|
||||||
|
style="background-color: var(--golden-ale); color: var(--black-stout);">
|
||||||
|
Join Team
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
<p class="italic text-gray-500">No teams available yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<h3 class="text-xl font-semibold mb-4" style="color: var(--irish-green);">Create New Team</h3>
|
||||||
|
<form action="/teams/create" method="post" class="mt-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="name" class="block text-sm font-medium mb-1">Team Name</label>
|
||||||
|
<input type="text" name="name" id="name" placeholder="Enter team name" required
|
||||||
|
class="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2"
|
||||||
|
style="border-color: var(--irish-green); focus:ring-color: var(--irish-green);">
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full px-4 py-2 text-white rounded-md font-medium"
|
||||||
|
style="background-color: var(--irish-green);">
|
||||||
|
Create Team
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-10 bg-white p-6 rounded-lg shadow-md">
|
||||||
|
<h3 class="text-xl font-semibold mb-4" style="color: var(--irish-green);">About Teams</h3>
|
||||||
|
<p class="mb-4">
|
||||||
|
Teams are the heart of LeagueLedger. Join an existing team or create your own to start tracking your pub quiz triumphs!
|
||||||
|
</p>
|
||||||
|
<p class="italic">
|
||||||
|
Every point counts in the journey to becoming pub quiz champions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mx-auto p-4">
|
||||||
|
<h1 class="text-2xl font-bold text-irish-green mb-4">Terms of Service</h1>
|
||||||
|
<p class="mb-4">
|
||||||
|
Welcome to LeagueLedger! By using our platform, you agree to comply with the following terms and conditions.
|
||||||
|
</p>
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Acceptable Use</h2>
|
||||||
|
<ul class="list-disc ml-6 mb-4">
|
||||||
|
<li>No cheating or unfair practices</li>
|
||||||
|
<li>Respectful communication with other users</li>
|
||||||
|
<li>Compliance with all applicable laws and regulations</li>
|
||||||
|
</ul>
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Liability Disclaimer</h2>
|
||||||
|
<p class="mb-4">
|
||||||
|
LeagueLedger is provided "as is" without any warranties. We are not liable for any damages arising from your use of the platform.
|
||||||
|
</p>
|
||||||
|
<h2 class="text-xl font-semibold text-irish-green mt-6 mb-2">Governing Law</h2>
|
||||||
|
<p class="mb-4">
|
||||||
|
These terms shall be governed by and construed in accordance with the laws of Germany.
|
||||||
|
</p>
|
||||||
|
<p class="mb-4">
|
||||||
|
This project is part of the KaufDeinQuiz platform and is operated as an Open-Source initiative. All rights reserved.
|
||||||
|
</p>
|
||||||
|
<p>Christian Louis IT Beratung und Medienproduktion is responsible for the operation of this platform.</p>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from pathlib import Path
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from starlette.templating import _TemplateResponse
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
# Create a Jinja2 Environment with the 'jinja2.ext.do' extension
|
||||||
|
class MyJinjaTemplates(Jinja2Templates):
|
||||||
|
def __init__(self, directory: str, **kwargs: Any):
|
||||||
|
super().__init__(directory=directory, **kwargs)
|
||||||
|
self.env.add_extension('jinja2.ext.do')
|
||||||
|
self.env.globals['now'] = datetime.now # Add 'now' function
|
||||||
|
|
||||||
|
# Create templates object that can be imported elsewhere
|
||||||
|
templates = MyJinjaTemplates(directory=str(BASE_DIR / "templates"))
|
||||||
|
|
||||||
|
# Register a context processor to add current_user to all templates
|
||||||
|
templates.env.globals["get_current_user"] = lambda: None # Will be overridden at runtime
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Admin interface for managing database records.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Request, Form, HTTPException, Query
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
import json
|
||||||
|
from typing import Dict, Any, List, Type, Optional
|
||||||
|
import inspect as py_inspect
|
||||||
|
|
||||||
|
from ..db import SessionLocal, Base
|
||||||
|
from ..models import User, Team, TeamMembership, QRTicket
|
||||||
|
from ..templates_config import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Dictionary of model classes with their display names
|
||||||
|
MODELS = {
|
||||||
|
'user': (User, "Users"),
|
||||||
|
'team': (Team, "Teams"),
|
||||||
|
'team_membership': (TeamMembership, "Team Memberships"),
|
||||||
|
'qr_ticket': (QRTicket, "QR Tickets"),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def get_model_info(model_class: Type[Base]) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Get column information for a model."""
|
||||||
|
mapper = inspect(model_class)
|
||||||
|
columns = {}
|
||||||
|
|
||||||
|
for column in mapper.columns:
|
||||||
|
is_primary = column.primary_key
|
||||||
|
is_foreign_key = bool(column.foreign_keys)
|
||||||
|
foreign_key_target = None
|
||||||
|
|
||||||
|
if is_foreign_key:
|
||||||
|
for fk in column.foreign_keys:
|
||||||
|
foreign_key_target = fk.target_fullname
|
||||||
|
|
||||||
|
columns[column.name] = {
|
||||||
|
'type': str(column.type),
|
||||||
|
'nullable': column.nullable,
|
||||||
|
'primary_key': is_primary,
|
||||||
|
'foreign_key': is_foreign_key,
|
||||||
|
'foreign_key_target': foreign_key_target,
|
||||||
|
}
|
||||||
|
|
||||||
|
return columns
|
||||||
|
|
||||||
|
def get_relationships(model_class: Type[Base]) -> Dict[str, str]:
|
||||||
|
"""Get relationship information for a model."""
|
||||||
|
relationships = {}
|
||||||
|
for name, rel in py_inspect.getmembers(model_class, lambda o: hasattr(o, 'prop')):
|
||||||
|
if hasattr(rel.prop, 'target'):
|
||||||
|
relationships[name] = rel.prop.target.name
|
||||||
|
return relationships
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def admin_home(request: Request):
|
||||||
|
"""Admin dashboard home."""
|
||||||
|
model_list = [(key, name) for key, (_, name) in MODELS.items()]
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/index.html",
|
||||||
|
{"request": request, "models": model_list}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/{model_name}", response_class=HTMLResponse)
|
||||||
|
async def list_records(
|
||||||
|
request: Request,
|
||||||
|
model_name: str,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
per_page: int = Query(10, ge=5, le=100),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""List records for a model with pagination."""
|
||||||
|
if model_name not in MODELS:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
||||||
|
|
||||||
|
model_class, display_name = MODELS[model_name]
|
||||||
|
|
||||||
|
# Get total count for pagination
|
||||||
|
total_records = db.query(model_class).count()
|
||||||
|
total_pages = (total_records + per_page - 1) // per_page
|
||||||
|
|
||||||
|
# Get records with pagination
|
||||||
|
records = db.query(model_class).offset((page - 1) * per_page).limit(per_page).all()
|
||||||
|
|
||||||
|
# Get column information
|
||||||
|
columns_info = get_model_info(model_class)
|
||||||
|
|
||||||
|
# Prepare column names for display
|
||||||
|
column_names = list(columns_info.keys())
|
||||||
|
|
||||||
|
# Extract values for each record
|
||||||
|
records_data = []
|
||||||
|
for record in records:
|
||||||
|
record_data = {}
|
||||||
|
for col in column_names:
|
||||||
|
record_data[col] = getattr(record, col)
|
||||||
|
records_data.append(record_data)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/list.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"model_name": model_name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"records": records_data,
|
||||||
|
"columns": column_names,
|
||||||
|
"columns_info": columns_info,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"total_records": total_records,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/{model_name}/new", response_class=HTMLResponse)
|
||||||
|
async def create_record_form(
|
||||||
|
request: Request,
|
||||||
|
model_name: str,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Show form for creating a new record."""
|
||||||
|
if model_name not in MODELS:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
||||||
|
|
||||||
|
model_class, display_name = MODELS[model_name]
|
||||||
|
|
||||||
|
# Get column information
|
||||||
|
columns_info = get_model_info(model_class)
|
||||||
|
|
||||||
|
# For foreign keys, fetch possible values
|
||||||
|
foreign_key_options = {}
|
||||||
|
for col_name, info in columns_info.items():
|
||||||
|
if info['foreign_key'] and info['foreign_key_target']:
|
||||||
|
target_table, target_col = info['foreign_key_target'].split('.')
|
||||||
|
# Try to find the corresponding model class
|
||||||
|
for model_key, (model_cls, _) in MODELS.items():
|
||||||
|
if model_cls.__tablename__ == target_table:
|
||||||
|
# Fetch options for this foreign key
|
||||||
|
options = db.query(model_cls).all()
|
||||||
|
foreign_key_options[col_name] = [(getattr(option, 'id'), str(option)) for option in options]
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/edit.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"model_name": model_name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"columns_info": columns_info,
|
||||||
|
"record": None, # No record for new form
|
||||||
|
"foreign_key_options": foreign_key_options,
|
||||||
|
"is_new": True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/{model_name}/new")
|
||||||
|
async def create_record(
|
||||||
|
request: Request,
|
||||||
|
model_name: str,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Create a new record."""
|
||||||
|
if model_name not in MODELS:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
||||||
|
|
||||||
|
model_class, _ = MODELS[model_name]
|
||||||
|
|
||||||
|
# Get form data from request
|
||||||
|
form_data = await request.form()
|
||||||
|
|
||||||
|
# Convert form data to appropriate types
|
||||||
|
columns_info = get_model_info(model_class)
|
||||||
|
record_data = {}
|
||||||
|
|
||||||
|
for field_name, value in form_data.items():
|
||||||
|
if field_name in columns_info:
|
||||||
|
col_type = columns_info[field_name]['type'].lower()
|
||||||
|
|
||||||
|
# Skip empty values for nullable fields
|
||||||
|
if value == '' and columns_info[field_name]['nullable']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert values based on column type
|
||||||
|
if 'int' in col_type:
|
||||||
|
if value:
|
||||||
|
record_data[field_name] = int(value)
|
||||||
|
elif 'bool' in col_type or 'boolean' in col_type:
|
||||||
|
record_data[field_name] = value.lower() in ('true', 'yes', 'y', '1', 'on', 'checked')
|
||||||
|
else:
|
||||||
|
record_data[field_name] = value
|
||||||
|
|
||||||
|
# Skip primary key for new records if it's auto-increment
|
||||||
|
for col_name, info in columns_info.items():
|
||||||
|
if info['primary_key'] and col_name not in record_data:
|
||||||
|
pass # Skip primary key
|
||||||
|
|
||||||
|
# Create record
|
||||||
|
new_record = model_class(**record_data)
|
||||||
|
db.add(new_record)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse(f"/admin/{model_name}", status_code=303)
|
||||||
|
|
||||||
|
@router.get("/{model_name}/{record_id}", response_class=HTMLResponse)
|
||||||
|
async def edit_record_form(
|
||||||
|
request: Request,
|
||||||
|
model_name: str,
|
||||||
|
record_id: int,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Show form for editing an existing record."""
|
||||||
|
if model_name not in MODELS:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
||||||
|
|
||||||
|
model_class, display_name = MODELS[model_name]
|
||||||
|
|
||||||
|
# Get the record
|
||||||
|
record = db.query(model_class).filter_by(id=record_id).first()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Record not found")
|
||||||
|
|
||||||
|
# Get column information
|
||||||
|
columns_info = get_model_info(model_class)
|
||||||
|
|
||||||
|
# For foreign keys, fetch possible values
|
||||||
|
foreign_key_options = {}
|
||||||
|
for col_name, info in columns_info.items():
|
||||||
|
if info['foreign_key'] and info['foreign_key_target']:
|
||||||
|
target_table, target_col = info['foreign_key_target'].split('.')
|
||||||
|
# Try to find the corresponding model class
|
||||||
|
for model_key, (model_cls, _) in MODELS.items():
|
||||||
|
if model_cls.__tablename__ == target_table:
|
||||||
|
# Fetch options for this foreign key
|
||||||
|
options = db.query(model_cls).all()
|
||||||
|
foreign_key_options[col_name] = [(getattr(option, 'id'), str(option)) for option in options]
|
||||||
|
|
||||||
|
# Prepare record data
|
||||||
|
record_data = {}
|
||||||
|
for col_name in columns_info:
|
||||||
|
record_data[col_name] = getattr(record, col_name)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin/edit.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"model_name": model_name,
|
||||||
|
"display_name": display_name,
|
||||||
|
"columns_info": columns_info,
|
||||||
|
"record": record_data,
|
||||||
|
"foreign_key_options": foreign_key_options,
|
||||||
|
"is_new": False
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/{model_name}/{record_id}")
|
||||||
|
async def update_record(
|
||||||
|
request: Request,
|
||||||
|
model_name: str,
|
||||||
|
record_id: int,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update an existing record."""
|
||||||
|
if model_name not in MODELS:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
||||||
|
|
||||||
|
model_class, _ = MODELS[model_name]
|
||||||
|
|
||||||
|
# Get the record
|
||||||
|
record = db.query(model_class).filter_by(id=record_id).first()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Record not found")
|
||||||
|
|
||||||
|
# Get form data from request
|
||||||
|
form_data = await request.form()
|
||||||
|
|
||||||
|
# Convert form data to appropriate types and update record
|
||||||
|
columns_info = get_model_info(model_class)
|
||||||
|
|
||||||
|
for field_name, value in form_data.items():
|
||||||
|
if field_name in columns_info and not columns_info[field_name]['primary_key']:
|
||||||
|
col_type = columns_info[field_name]['type'].lower()
|
||||||
|
|
||||||
|
# Handle nullable fields
|
||||||
|
if value == '' and columns_info[field_name]['nullable']:
|
||||||
|
setattr(record, field_name, None)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Convert values based on column type
|
||||||
|
if 'int' in col_type:
|
||||||
|
if value:
|
||||||
|
setattr(record, field_name, int(value))
|
||||||
|
elif 'bool' in col_type or 'boolean' in col_type:
|
||||||
|
bool_value = value.lower() in ('true', 'yes', 'y', '1', 'on', 'checked')
|
||||||
|
setattr(record, field_name, bool_value)
|
||||||
|
else:
|
||||||
|
setattr(record, field_name, value)
|
||||||
|
|
||||||
|
# Save changes
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse(f"/admin/{model_name}", status_code=303)
|
||||||
|
|
||||||
|
@router.get("/{model_name}/{record_id}/delete")
|
||||||
|
async def delete_record(
|
||||||
|
model_name: str,
|
||||||
|
record_id: int,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Delete a record."""
|
||||||
|
if model_name not in MODELS:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Model {model_name} not found")
|
||||||
|
|
||||||
|
model_class, _ = MODELS[model_name]
|
||||||
|
|
||||||
|
# Get the record
|
||||||
|
record = db.query(model_class).filter_by(id=record_id).first()
|
||||||
|
if not record:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Record not found")
|
||||||
|
|
||||||
|
# Delete record
|
||||||
|
db.delete(record)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse(f"/admin/{model_name}", status_code=303)
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Authentication routes for user login, registration, and management.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, Response
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
import os
|
||||||
|
|
||||||
|
from ..db import SessionLocal, engine
|
||||||
|
from ..models import User
|
||||||
|
from ..security import (
|
||||||
|
verify_password, get_password_hash, create_access_token, generate_token,
|
||||||
|
SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
|
||||||
|
)
|
||||||
|
from ..dependencies import get_db, get_current_user, get_user_from_session
|
||||||
|
from ..templates_config import templates
|
||||||
|
from ..schemas import UserCreate, UserLogin, UserUpdate, PasswordReset
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Check if all required user columns exist
|
||||||
|
def get_available_user_columns():
|
||||||
|
inspector = inspect(engine)
|
||||||
|
if 'users' in inspector.get_table_names():
|
||||||
|
return [col['name'] for col in inspector.get_columns('users')]
|
||||||
|
return []
|
||||||
|
|
||||||
|
# --- HTML ROUTES (Web UI) ---
|
||||||
|
|
||||||
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request, next: str = "/"):
|
||||||
|
"""Display login page."""
|
||||||
|
# Debug print to check what's happening in the route
|
||||||
|
print("Login page accessed, checking session...")
|
||||||
|
|
||||||
|
# DO NOT redirect if user is logged in - there's likely an issue with session handling
|
||||||
|
# Just render the login page regardless of session state for now
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/login.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"next": next,
|
||||||
|
"messages": []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/login", response_class=HTMLResponse)
|
||||||
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
username: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
remember: bool = Form(False),
|
||||||
|
next: str = Form("/")
|
||||||
|
):
|
||||||
|
"""Process login form."""
|
||||||
|
# Try to authenticate user
|
||||||
|
user = db.query(User).filter((User.username == username) | (User.email == username)).first()
|
||||||
|
|
||||||
|
if not user or not verify_password(password, user.hashed_password):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/login.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"next": next,
|
||||||
|
"messages": [{"type": "error", "text": "Invalid username or password"}],
|
||||||
|
"username": username
|
||||||
|
},
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if is_active column exists and if user is active
|
||||||
|
columns = get_available_user_columns()
|
||||||
|
if 'is_active' in columns and hasattr(user, 'is_active') and not user.is_active:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/login.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"next": next,
|
||||||
|
"messages": [{"type": "error", "text": "Account is deactivated"}],
|
||||||
|
"username": username
|
||||||
|
},
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update last login time if column exists
|
||||||
|
if 'last_login' in columns and hasattr(user, 'last_login'):
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Debug info
|
||||||
|
print(f"User authenticated: {user.username}")
|
||||||
|
|
||||||
|
# Set session data with better error handling
|
||||||
|
try:
|
||||||
|
# Use directly accessible dictionary
|
||||||
|
request.session["user_id"] = user.id
|
||||||
|
request.session["username"] = user.username
|
||||||
|
request.session["_permanent"] = True
|
||||||
|
|
||||||
|
# Check if is_admin attribute exists
|
||||||
|
if hasattr(user, "is_admin"):
|
||||||
|
request.session["is_admin"] = user.is_admin
|
||||||
|
else:
|
||||||
|
request.session["is_admin"] = False
|
||||||
|
|
||||||
|
# Add timestamp for session creation
|
||||||
|
request.session["created_at"] = str(datetime.now())
|
||||||
|
|
||||||
|
# Debug session data
|
||||||
|
print(f"Session data set: {dict(request.session)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error setting session: {str(e)}")
|
||||||
|
|
||||||
|
# Redirect to next page or home
|
||||||
|
return RedirectResponse(url=next, status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
@router.get("/register", response_class=HTMLResponse)
|
||||||
|
async def register_page(request: Request):
|
||||||
|
"""Display registration page."""
|
||||||
|
# Check if user is already logged in
|
||||||
|
user = await get_user_from_session(request)
|
||||||
|
if user:
|
||||||
|
return RedirectResponse(url="/")
|
||||||
|
|
||||||
|
return templates.TemplateResponse("auth/register.html", {"request": request})
|
||||||
|
|
||||||
|
@router.post("/register", response_class=HTMLResponse)
|
||||||
|
async def register(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
username: str = Form(...),
|
||||||
|
email: str = Form(...),
|
||||||
|
password: str = Form(...),
|
||||||
|
confirm_password: str = Form(...),
|
||||||
|
):
|
||||||
|
"""Process registration form."""
|
||||||
|
# Validate the form data
|
||||||
|
if password != confirm_password:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/register.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"messages": [{"type": "error", "text": "Passwords do not match"}],
|
||||||
|
"username": username,
|
||||||
|
"email": email
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if username already exists
|
||||||
|
if db.query(User).filter(User.username == username).first():
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/register.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"messages": [{"type": "error", "text": "Username already exists"}],
|
||||||
|
"username": username,
|
||||||
|
"email": email
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if email already exists
|
||||||
|
if db.query(User).filter(User.email == email).first():
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/register.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"messages": [{"type": "error", "text": "Email already exists"}],
|
||||||
|
"username": username,
|
||||||
|
"email": email
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create new user
|
||||||
|
hashed_password = get_password_hash(password)
|
||||||
|
|
||||||
|
# Get available columns
|
||||||
|
columns = get_available_user_columns()
|
||||||
|
user_data = {
|
||||||
|
"username": username,
|
||||||
|
"email": email,
|
||||||
|
"hashed_password": hashed_password
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add optional fields only if they exist in the database
|
||||||
|
if 'is_active' in columns:
|
||||||
|
user_data["is_active"] = True
|
||||||
|
if 'is_verified' in columns:
|
||||||
|
user_data["is_verified"] = False
|
||||||
|
if 'verification_token' in columns:
|
||||||
|
user_data["verification_token"] = generate_token()
|
||||||
|
|
||||||
|
new_user = User(**user_data)
|
||||||
|
|
||||||
|
db.add(new_user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_user)
|
||||||
|
|
||||||
|
# In a real app, send verification email here
|
||||||
|
# For now, just redirect to a success page
|
||||||
|
|
||||||
|
# Log the user in
|
||||||
|
request.session["user_id"] = new_user.id
|
||||||
|
|
||||||
|
return RedirectResponse(
|
||||||
|
url="/auth/registration-success",
|
||||||
|
status_code=status.HTTP_303_SEE_OTHER
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/registration-success", response_class=HTMLResponse)
|
||||||
|
async def registration_success(request: Request):
|
||||||
|
"""Display registration success page."""
|
||||||
|
return templates.TemplateResponse("auth/registration_success.html", {"request": request})
|
||||||
|
|
||||||
|
@router.get("/logout")
|
||||||
|
async def logout(request: Request):
|
||||||
|
"""Log user out by clearing session."""
|
||||||
|
request.session.clear()
|
||||||
|
return RedirectResponse(url="/")
|
||||||
|
|
||||||
|
@router.get("/profile", response_class=HTMLResponse)
|
||||||
|
async def profile_page(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Display user profile page."""
|
||||||
|
user = await get_user_from_session(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/auth/login?next=/auth/profile")
|
||||||
|
|
||||||
|
# Get user's teams (using fresh user object from database to ensure relationships are loaded)
|
||||||
|
user = db.query(User).filter(User.id == user.id).first()
|
||||||
|
|
||||||
|
user_teams = []
|
||||||
|
for membership in user.memberships:
|
||||||
|
user_teams.append({
|
||||||
|
"team": membership.team,
|
||||||
|
"is_admin": membership.is_admin
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/profile.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"user_teams": user_teams
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/update-profile", response_class=HTMLResponse)
|
||||||
|
async def update_profile(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
username: str = Form(None),
|
||||||
|
email: str = Form(None),
|
||||||
|
):
|
||||||
|
"""Update user profile information."""
|
||||||
|
user = await get_user_from_session(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/auth/login?next=/auth/profile")
|
||||||
|
|
||||||
|
# Check for username collision
|
||||||
|
if username and username != user.username:
|
||||||
|
existing_user = db.query(User).filter(User.username == username).first()
|
||||||
|
if existing_user:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/profile.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"messages": [{"type": "error", "text": "Username already exists"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
user.username = username
|
||||||
|
|
||||||
|
# Check for email collision
|
||||||
|
if email and email != user.email:
|
||||||
|
existing_user = db.query(User).filter(User.email == email).first()
|
||||||
|
if existing_user:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/profile.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"messages": [{"type": "error", "text": "Email already exists"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
user.email = email
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/profile.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"messages": [{"type": "success", "text": "Profile updated successfully"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/change-password", response_class=HTMLResponse)
|
||||||
|
async def change_password(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_password: str = Form(...),
|
||||||
|
new_password: str = Form(...),
|
||||||
|
confirm_password: str = Form(...),
|
||||||
|
):
|
||||||
|
"""Change user password."""
|
||||||
|
user = await get_user_from_session(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/auth/login?next=/auth/profile")
|
||||||
|
|
||||||
|
# Verify current password
|
||||||
|
if not verify_password(current_password, user.hashed_password):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/profile.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"messages": [{"type": "error", "text": "Current password is incorrect"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if new passwords match
|
||||||
|
if new_password != confirm_password:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/profile.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"messages": [{"type": "error", "text": "New passwords do not match"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
user.hashed_password = get_password_hash(new_password)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/profile.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"messages": [{"type": "success", "text": "Password changed successfully"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/forgot-password", response_class=HTMLResponse)
|
||||||
|
async def forgot_password_page(request: Request):
|
||||||
|
"""Display forgot password page."""
|
||||||
|
return templates.TemplateResponse("auth/forgot_password.html", {"request": request})
|
||||||
|
|
||||||
|
@router.post("/forgot-password")
|
||||||
|
async def forgot_password(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
email: str = Form(...)
|
||||||
|
):
|
||||||
|
"""Process forgot password form."""
|
||||||
|
# Find user by email
|
||||||
|
user = db.query(User).filter(User.email == email).first()
|
||||||
|
|
||||||
|
# Always show success to prevent email enumeration
|
||||||
|
if not user:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/forgot_password_sent.html",
|
||||||
|
{"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate reset token
|
||||||
|
reset_token = generate_token()
|
||||||
|
user.reset_token = reset_token
|
||||||
|
user.reset_token_expires_at = datetime.utcnow() + timedelta(hours=1)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# In a real app, send email with reset link
|
||||||
|
# For demo, just show the reset link on the success page
|
||||||
|
reset_url = f"/auth/reset-password?token={reset_token}"
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/forgot_password_sent.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"reset_url": reset_url # Remove in production, just for demo
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/reset-password", response_class=HTMLResponse)
|
||||||
|
async def reset_password_page(
|
||||||
|
request: Request,
|
||||||
|
token: str,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Display reset password page."""
|
||||||
|
# Check if token exists and is valid
|
||||||
|
user = db.query(User).filter(
|
||||||
|
User.reset_token == token,
|
||||||
|
User.reset_token_expires_at > datetime.utcnow()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/reset_password_error.html",
|
||||||
|
{"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/reset_password.html",
|
||||||
|
{"request": request, "token": token}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/reset-password")
|
||||||
|
async def reset_password(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
token: str = Form(...),
|
||||||
|
new_password: str = Form(...),
|
||||||
|
confirm_password: str = Form(...)
|
||||||
|
):
|
||||||
|
"""Process reset password form."""
|
||||||
|
# Check if token exists and is valid
|
||||||
|
user = db.query(User).filter(
|
||||||
|
User.reset_token == token,
|
||||||
|
User.reset_token_expires_at > datetime.utcnow()
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/reset_password_error.html",
|
||||||
|
{"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if passwords match
|
||||||
|
if new_password != confirm_password:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/reset_password.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"token": token,
|
||||||
|
"messages": [{"type": "error", "text": "Passwords do not match"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
user.hashed_password = get_password_hash(new_password)
|
||||||
|
user.reset_token = None
|
||||||
|
user.reset_token_expires_at = None
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/reset_password_success.html",
|
||||||
|
{"request": request}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/delete-account", response_class=HTMLResponse)
|
||||||
|
async def delete_account_page(request: Request):
|
||||||
|
"""Display delete account confirmation page."""
|
||||||
|
user = await get_user_from_session(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/auth/login?next=/auth/delete-account")
|
||||||
|
|
||||||
|
return templates.TemplateResponse("auth/delete_account.html", {"request": request})
|
||||||
|
|
||||||
|
@router.post("/delete-account")
|
||||||
|
async def delete_account(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
password: str = Form(...)
|
||||||
|
):
|
||||||
|
"""Process account deletion."""
|
||||||
|
user = await get_user_from_session(request)
|
||||||
|
if not user:
|
||||||
|
return RedirectResponse(url="/auth/login?next=/auth/delete-account")
|
||||||
|
|
||||||
|
# Verify password
|
||||||
|
if not verify_password(password, user.hashed_password):
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"auth/delete_account.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"messages": [{"type": "error", "text": "Incorrect password"}]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# In a real app, you might want to anonymize the user data instead
|
||||||
|
# of deleting it completely, but for this demo we'll delete
|
||||||
|
|
||||||
|
# Clear session
|
||||||
|
request.session.clear()
|
||||||
|
|
||||||
|
# Delete user
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
|
||||||
|
|
||||||
|
# --- API ROUTES (for potential SPA frontend) ---
|
||||||
|
|
||||||
|
@router.post("/token")
|
||||||
|
async def login_api(
|
||||||
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""API login endpoint returning JWT token."""
|
||||||
|
# Authenticate user
|
||||||
|
user = db.query(User).filter((User.username == form_data.username) | (User.email == form_data.username)).first()
|
||||||
|
|
||||||
|
if not user or not verify_password(form_data.password, user.hashed_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect username or password",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
columns = get_available_user_columns()
|
||||||
|
if 'is_active' in columns and hasattr(user, 'is_active') and not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Inactive user",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'last_login' in columns and hasattr(user, 'last_login'):
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Create access token
|
||||||
|
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": user.username},
|
||||||
|
expires_delta=access_token_expires
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
"user_id": user.id,
|
||||||
|
"username": user.username
|
||||||
|
}
|
||||||
|
|
||||||
|
@router.get("/session-test")
|
||||||
|
async def test_session(request: Request):
|
||||||
|
"""Test endpoint to verify session data persistence"""
|
||||||
|
has_session = hasattr(request, "session")
|
||||||
|
session_data = dict(request.session) if has_session else {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has_session": has_session,
|
||||||
|
"session_data": session_data,
|
||||||
|
"authenticated": "user_id" in session_data
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Dashboard views for user-specific information.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Request, Form
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
from ..db import SessionLocal
|
||||||
|
from ..models import User, Team, TeamMembership, QRTicket
|
||||||
|
from ..templates_config import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def get_or_create_default_user(db: Session):
|
||||||
|
"""Get user ID 1 or create it if it doesn't exist."""
|
||||||
|
user = db.query(User).filter_by(id=1).first()
|
||||||
|
if not user:
|
||||||
|
# Create a default user
|
||||||
|
user = User(
|
||||||
|
username="default_user",
|
||||||
|
email="default@example.com",
|
||||||
|
hashed_password="placeholder"
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def user_dashboard(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Show the user's dashboard with team info and recent activity."""
|
||||||
|
|
||||||
|
# Get current user - using a default user for now
|
||||||
|
# In a real app, this would come from auth system
|
||||||
|
user = get_or_create_default_user(db)
|
||||||
|
|
||||||
|
# Get user's teams
|
||||||
|
user_teams = db.query(Team).join(
|
||||||
|
TeamMembership,
|
||||||
|
TeamMembership.team_id == Team.id
|
||||||
|
).filter(
|
||||||
|
TeamMembership.user_id == user.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Get team memberships with admin status
|
||||||
|
team_memberships = db.query(
|
||||||
|
TeamMembership
|
||||||
|
).filter(
|
||||||
|
TeamMembership.user_id == user.id
|
||||||
|
).all()
|
||||||
|
|
||||||
|
admin_team_ids = [tm.team_id for tm in team_memberships if tm.is_admin]
|
||||||
|
|
||||||
|
# Get points per team
|
||||||
|
team_points = {}
|
||||||
|
for team in user_teams:
|
||||||
|
points = db.query(func.sum(QRTicket.points)).filter(
|
||||||
|
QRTicket.redeemed_at_team == team.id
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Get team ranking - simplified approach
|
||||||
|
higher_teams = db.query(func.count(Team.id)).join(
|
||||||
|
QRTicket,
|
||||||
|
QRTicket.redeemed_at_team == Team.id
|
||||||
|
).group_by(
|
||||||
|
Team.id
|
||||||
|
).having(
|
||||||
|
func.sum(QRTicket.points) > points
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
rank = higher_teams + 1
|
||||||
|
|
||||||
|
team_points[team.id] = {
|
||||||
|
'points': points,
|
||||||
|
'rank': rank
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get recent activity
|
||||||
|
# For simplicity, we're just getting recent QR code redemptions
|
||||||
|
recent_activity = []
|
||||||
|
|
||||||
|
recent_tickets = db.query(QRTicket).filter(
|
||||||
|
QRTicket.redeemed_by == user.id
|
||||||
|
).order_by(
|
||||||
|
QRTicket.id.desc() # Assuming higher ID = newer
|
||||||
|
).limit(5).all()
|
||||||
|
|
||||||
|
for ticket in recent_tickets:
|
||||||
|
team = db.query(Team).filter(Team.id == ticket.redeemed_at_team).first()
|
||||||
|
activity = {
|
||||||
|
'type': 'qr_redeem',
|
||||||
|
'points': ticket.points,
|
||||||
|
'team_name': team.name if team else "Unknown team",
|
||||||
|
'date': "Recently" # Placeholder - would use ticket.created_at
|
||||||
|
}
|
||||||
|
recent_activity.append(activity)
|
||||||
|
|
||||||
|
# Get total points for user across all teams
|
||||||
|
total_points = sum(team_data['points'] for team_data in team_points.values())
|
||||||
|
|
||||||
|
# Get best ranking
|
||||||
|
best_rank = min(team_data['rank'] for team_data in team_points.values()) if team_points else None
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"dashboard.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"user": user,
|
||||||
|
"teams": user_teams,
|
||||||
|
"team_points": team_points,
|
||||||
|
"admin_team_ids": admin_team_ids,
|
||||||
|
"recent_activity": recent_activity,
|
||||||
|
"total_points": total_points,
|
||||||
|
"best_rank": best_rank,
|
||||||
|
"team_count": len(user_teams)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.get("/scan", response_class=HTMLResponse)
|
||||||
|
async def scan_qr(request: Request):
|
||||||
|
"""Show QR scanning interface."""
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"scan_qr.html",
|
||||||
|
{"request": request}
|
||||||
|
)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Leaderboard views for displaying team rankings.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Request, Query
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, desc
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from ..db import SessionLocal
|
||||||
|
from ..models import Team, TeamMembership, QRTicket
|
||||||
|
from ..templates_config import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def show_leaderboard(
|
||||||
|
request: Request,
|
||||||
|
timeframe: str = Query("all", regex="^(week|month|all)$"),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Show the leaderboard with team rankings."""
|
||||||
|
|
||||||
|
# Define cutoff date based on timeframe
|
||||||
|
cutoff_date = None
|
||||||
|
if timeframe == "week":
|
||||||
|
cutoff_date = datetime.now() - timedelta(days=7)
|
||||||
|
time_label = "This Week"
|
||||||
|
elif timeframe == "month":
|
||||||
|
cutoff_date = datetime.now() - timedelta(days=30)
|
||||||
|
time_label = "This Month"
|
||||||
|
else:
|
||||||
|
timeframe = "all" # Ensure valid value
|
||||||
|
time_label = "All Time"
|
||||||
|
|
||||||
|
# Base query to get teams
|
||||||
|
query = db.query(
|
||||||
|
Team.id,
|
||||||
|
Team.name,
|
||||||
|
func.coalesce(func.sum(QRTicket.points), 0).label('total_points')
|
||||||
|
).join(
|
||||||
|
QRTicket,
|
||||||
|
QRTicket.redeemed_at_team == Team.id,
|
||||||
|
isouter=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply time filter if needed
|
||||||
|
if cutoff_date:
|
||||||
|
# Note: This assumes QRTicket has a created_at or similar timestamp field
|
||||||
|
# If not, you would need to add one to track when points were added
|
||||||
|
# For now, this is a placeholder that assumes all tickets are from "now"
|
||||||
|
# query = query.filter(QRTicket.created_at >= cutoff_date)
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Group and order
|
||||||
|
teams_ranking = query.group_by(Team.id).order_by(desc('total_points')).all()
|
||||||
|
|
||||||
|
# Add ranks
|
||||||
|
ranked_teams = []
|
||||||
|
for idx, team in enumerate(teams_ranking):
|
||||||
|
ranked_teams.append({
|
||||||
|
'rank': idx + 1,
|
||||||
|
'id': team.id,
|
||||||
|
'name': team.name,
|
||||||
|
'points': team.total_points,
|
||||||
|
'change': 0 # Placeholder for rank change - would require historical data
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get top 3 teams for podium display
|
||||||
|
top_teams = ranked_teams[:3] if len(ranked_teams) >= 3 else ranked_teams + [None] * (3 - len(ranked_teams))
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"leaderboard.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"teams": ranked_teams,
|
||||||
|
"top_teams": top_teams,
|
||||||
|
"timeframe": timeframe,
|
||||||
|
"time_label": time_label
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate QR codes for top teams (quiz master).
|
||||||
|
"""
|
||||||
|
import qrcode
|
||||||
|
import io
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..db import SessionLocal
|
||||||
|
from ..models import QRTicket
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.get("/generate/{points}")
|
||||||
|
def generate_qr(points: int, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Generate a QR code for awarding `points` points.
|
||||||
|
Saves a record in the DB, returns the PNG as streaming response.
|
||||||
|
"""
|
||||||
|
code_str = str(uuid.uuid4())
|
||||||
|
|
||||||
|
ticket = QRTicket(code=code_str, points=points)
|
||||||
|
db.add(ticket)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(ticket)
|
||||||
|
|
||||||
|
qr_img = qrcode.make(code_str)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
qr_img.save(buf, format="PNG")
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
|
return StreamingResponse(buf, media_type="image/png")
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Redeem a QR code and attribute points to a team.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Request, Form, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ..db import SessionLocal
|
||||||
|
from ..models import QRTicket, User, Team, TeamMembership
|
||||||
|
from ..templates_config import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
@router.get("/{code}", response_class=HTMLResponse)
|
||||||
|
def redeem_code(code: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Display a page to let the user choose which team to apply points to.
|
||||||
|
If not logged in, prompt them.
|
||||||
|
"""
|
||||||
|
# In real app, you'd check user session or redirect to login
|
||||||
|
ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
|
||||||
|
if not ticket:
|
||||||
|
return "Invalid or already used code."
|
||||||
|
|
||||||
|
# Skeleton: you'd get the user's teams from session user
|
||||||
|
# For now, we mock a user ID = 1:
|
||||||
|
user = db.query(User).filter_by(id=1).first()
|
||||||
|
if not user:
|
||||||
|
return "User not found. Please log in."
|
||||||
|
|
||||||
|
# This is where you'd show the team selection or "create new team" UI
|
||||||
|
user_teams = [m.team for m in user.memberships]
|
||||||
|
|
||||||
|
return templates.TemplateResponse("redeem.html", {
|
||||||
|
"request": request,
|
||||||
|
"ticket": ticket,
|
||||||
|
"user_teams": user_teams
|
||||||
|
})
|
||||||
|
|
||||||
|
@router.post("/apply/{code}")
|
||||||
|
async def apply_code(
|
||||||
|
request: Request,
|
||||||
|
code: str,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Apply the QR code to a selected team (if user is a member),
|
||||||
|
or set to pending if user isn't a member yet.
|
||||||
|
"""
|
||||||
|
# Get form data
|
||||||
|
form_data = await request.form()
|
||||||
|
team_id = int(form_data.get("team_id", 0))
|
||||||
|
|
||||||
|
if team_id <= 0:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"error_title": "Team Selection Required",
|
||||||
|
"error_message": "Please select a team to redeem this code."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
|
||||||
|
if not ticket:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"error_title": "Invalid Code",
|
||||||
|
"error_message": "This code is invalid or has already been used."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# For skeleton, assume user = 1
|
||||||
|
user = db.query(User).filter_by(id=1).first()
|
||||||
|
team = db.query(Team).filter_by(id=team_id).first()
|
||||||
|
|
||||||
|
if not team:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"error_title": "Team Not Found",
|
||||||
|
"error_message": "The selected team could not be found."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check membership
|
||||||
|
membership = db.query(TeamMembership).filter_by(user_id=user.id, team_id=team.id).first()
|
||||||
|
if membership:
|
||||||
|
# Redeem
|
||||||
|
ticket.redeemed_by = user.id
|
||||||
|
ticket.redeemed_at_team = team.id
|
||||||
|
ticket.used = True
|
||||||
|
|
||||||
|
# If we have redeemed_at column, update it
|
||||||
|
if hasattr(ticket, 'redeemed_at'):
|
||||||
|
from datetime import datetime
|
||||||
|
ticket.redeemed_at = datetime.now()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# Redirect to success page or dashboard
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"redeem_success.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"points": ticket.points,
|
||||||
|
"team": team
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# In real app, create a pending record or request flow
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"error_title": "Not a Team Member",
|
||||||
|
"error_message": "You are not a member of this team. Please join the team first or select another team."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/manual")
|
||||||
|
async def manual_code_entry(
|
||||||
|
request: Request,
|
||||||
|
code: str = Form(...),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Handle manual code entry from the form.
|
||||||
|
This redirects to the normal redeem flow after validating the code.
|
||||||
|
"""
|
||||||
|
# Check if the code exists
|
||||||
|
ticket = db.query(QRTicket).filter_by(code=code, used=False).first()
|
||||||
|
|
||||||
|
if not ticket:
|
||||||
|
# In a real app, add a flash message or error handling
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"error_title": "Invalid Code",
|
||||||
|
"error_message": "The code you entered is invalid or has already been used."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Redirect to the regular redeem flow
|
||||||
|
return RedirectResponse(f"/redeem/{code}", status_code=303)
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Create or join a team, manage membership.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, Request, Form, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func, desc, inspect
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import random # For demo data
|
||||||
|
|
||||||
|
from ..db import SessionLocal
|
||||||
|
from ..models import Team, TeamMembership, User, QRTicket, TeamAchievement
|
||||||
|
from ..schemas import TeamCreate
|
||||||
|
from ..templates_config import templates
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def get_or_create_default_user(db: Session):
|
||||||
|
"""Get user ID 1 or create it if it doesn't exist."""
|
||||||
|
user = db.query(User).filter_by(id=1).first()
|
||||||
|
if not user:
|
||||||
|
# Create a default user
|
||||||
|
user = User(
|
||||||
|
username="default_user",
|
||||||
|
email="default@example.com",
|
||||||
|
hashed_password="placeholder"
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
def list_teams(request: Request, db: Session = Depends(get_db)):
|
||||||
|
teams = db.query(Team).all()
|
||||||
|
|
||||||
|
# Get the user's teams to highlight teams they're already in
|
||||||
|
user = db.query(User).filter_by(id=1).first()
|
||||||
|
user_team_ids = []
|
||||||
|
|
||||||
|
if user:
|
||||||
|
memberships = db.query(TeamMembership).filter_by(user_id=user.id).all()
|
||||||
|
user_team_ids = [m.team_id for m in memberships]
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"teams.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"teams": teams,
|
||||||
|
"user_team_ids": user_team_ids,
|
||||||
|
"brand_colors": {
|
||||||
|
"irish_green": "#006837",
|
||||||
|
"golden_ale": "#FFB400",
|
||||||
|
"cream_white": "#F5F0E1",
|
||||||
|
"black_stout": "#1A1A1A",
|
||||||
|
"guinness_red": "#B22222"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/create")
|
||||||
|
def create_team(name: str = Form(...), db: Session = Depends(get_db)):
|
||||||
|
user = get_or_create_default_user(db)
|
||||||
|
|
||||||
|
# Create team
|
||||||
|
new_team = Team(name=name)
|
||||||
|
db.add(new_team)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_team)
|
||||||
|
|
||||||
|
# Make user admin of team
|
||||||
|
membership = TeamMembership(user_id=user.id, team_id=new_team.id, is_admin=True)
|
||||||
|
db.add(membership)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse("/teams/", status_code=303)
|
||||||
|
|
||||||
|
@router.post("/join/{team_id}")
|
||||||
|
def join_team(team_id: int, db: Session = Depends(get_db)):
|
||||||
|
user = get_or_create_default_user(db)
|
||||||
|
|
||||||
|
team = db.query(Team).filter_by(id=team_id).first()
|
||||||
|
if not team:
|
||||||
|
return RedirectResponse("/teams/", status_code=303)
|
||||||
|
|
||||||
|
# Check if membership exists
|
||||||
|
existing = db.query(TeamMembership).filter_by(user_id=user.id, team_id=team.id).first()
|
||||||
|
if existing:
|
||||||
|
return RedirectResponse("/teams/", status_code=303)
|
||||||
|
|
||||||
|
# Create membership
|
||||||
|
new_member = TeamMembership(user_id=user.id, team_id=team.id, is_admin=False)
|
||||||
|
db.add(new_member)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse("/teams/", status_code=303)
|
||||||
|
|
||||||
|
@router.get("/{team_id}", response_class=HTMLResponse)
|
||||||
|
def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Show details for a specific 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 current user - using a default user for now
|
||||||
|
user = get_or_create_default_user(db)
|
||||||
|
|
||||||
|
# Get team members with admin status
|
||||||
|
memberships = db.query(TeamMembership).filter_by(team_id=team_id).all()
|
||||||
|
team_members = []
|
||||||
|
|
||||||
|
is_user_admin = False
|
||||||
|
for membership in memberships:
|
||||||
|
member = db.query(User).filter_by(id=membership.user_id).first()
|
||||||
|
if member:
|
||||||
|
# Check if current user is admin
|
||||||
|
if membership.user_id == user.id and membership.is_admin:
|
||||||
|
is_user_admin = True
|
||||||
|
|
||||||
|
# Use joined_at if available, otherwise use placeholder
|
||||||
|
joined_date = getattr(membership, 'joined_at', None) or datetime.now() - timedelta(days=random.randint(30, 180))
|
||||||
|
if isinstance(joined_date, datetime):
|
||||||
|
month_name = joined_date.strftime("%b")
|
||||||
|
year = joined_date.strftime("%Y")
|
||||||
|
else:
|
||||||
|
month_name = "Apr"
|
||||||
|
year = "2023"
|
||||||
|
|
||||||
|
team_members.append({
|
||||||
|
"user": member,
|
||||||
|
"is_admin": membership.is_admin,
|
||||||
|
"joined": f"{month_name} {year}"
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get total points
|
||||||
|
total_points = db.query(func.sum(QRTicket.points)).filter(
|
||||||
|
QRTicket.redeemed_at_team == team_id
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Calculate rank based on points
|
||||||
|
higher_teams = db.query(func.count(Team.id)).join(
|
||||||
|
QRTicket,
|
||||||
|
QRTicket.redeemed_at_team == Team.id,
|
||||||
|
isouter=True
|
||||||
|
).group_by(Team.id).having(
|
||||||
|
func.sum(QRTicket.points) > total_points
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
team_rank = higher_teams + 1
|
||||||
|
|
||||||
|
# Generate points data (with fallbacks for missing columns)
|
||||||
|
points_this_month = 65 # Default value
|
||||||
|
point_change = 15 # Default value
|
||||||
|
point_change_positive = True
|
||||||
|
|
||||||
|
# Check if redeemed_at column exists before using it
|
||||||
|
try:
|
||||||
|
now = datetime.now()
|
||||||
|
first_day_of_month = datetime(now.year, now.month, 1)
|
||||||
|
|
||||||
|
# Use raw SQL to check if column exists and get points
|
||||||
|
has_redeemed_at = False
|
||||||
|
inspector = inspect(db.bind)
|
||||||
|
if 'redeemed_at' in [col['name'] for col in inspector.get_columns('qr_tickets')]:
|
||||||
|
has_redeemed_at = True
|
||||||
|
|
||||||
|
if has_redeemed_at:
|
||||||
|
points_this_month = db.query(func.sum(QRTicket.points)).filter(
|
||||||
|
QRTicket.redeemed_at_team == team_id,
|
||||||
|
QRTicket.redeemed_at >= first_day_of_month
|
||||||
|
).scalar() or points_this_month
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error calculating monthly points: {e}")
|
||||||
|
|
||||||
|
# Activities - simple mock data for now
|
||||||
|
activities = [
|
||||||
|
{
|
||||||
|
"type": "points",
|
||||||
|
"points": 15,
|
||||||
|
"event": "Music Trivia Night",
|
||||||
|
"date": "September 12, 2023"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "join",
|
||||||
|
"user": "Robert Brown",
|
||||||
|
"date": "July 28, 2023"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "achievement",
|
||||||
|
"achievement": "1st place",
|
||||||
|
"event": "History Night",
|
||||||
|
"date": "July 15, 2023"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "points",
|
||||||
|
"points": 20,
|
||||||
|
"event": "Movie Trivia Night",
|
||||||
|
"date": "July 1, 2023"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Safely get team attributes
|
||||||
|
is_public = getattr(team, 'is_public', False)
|
||||||
|
created_at = getattr(team, 'created_at', None)
|
||||||
|
|
||||||
|
# Calculate days since team was founded
|
||||||
|
if created_at and isinstance(created_at, datetime):
|
||||||
|
days_ago = (datetime.now() - created_at).days
|
||||||
|
founded_date_str = created_at.strftime("%B %d, %Y")
|
||||||
|
else:
|
||||||
|
days_ago = 164 # Default fallback
|
||||||
|
founded_date_str = "March 22, 2023" # Default fallback
|
||||||
|
|
||||||
|
# Performance metrics
|
||||||
|
performance = {
|
||||||
|
"last_quiz": "25 points (2nd place)",
|
||||||
|
"average": "18.7 points",
|
||||||
|
"best_streak": "3 wins in a row"
|
||||||
|
}
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"team_detail.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"team": team,
|
||||||
|
"team_members": team_members,
|
||||||
|
"team_rank": team_rank,
|
||||||
|
"total_points": total_points,
|
||||||
|
"points_this_month": points_this_month,
|
||||||
|
"point_change": point_change,
|
||||||
|
"point_change_positive": point_change_positive,
|
||||||
|
"activities": activities,
|
||||||
|
"performance": performance,
|
||||||
|
"is_user_admin": is_user_admin,
|
||||||
|
"user": user,
|
||||||
|
"days_ago": days_ago,
|
||||||
|
"founded_date": founded_date_str
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@router.post("/{team_id}/update")
|
||||||
|
def update_team(
|
||||||
|
team_id: int,
|
||||||
|
team_name: str = Form(...),
|
||||||
|
is_public: bool = Form(False),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Update team details."""
|
||||||
|
user = get_or_create_default_user(db)
|
||||||
|
team = db.query(Team).filter_by(id=team_id).first()
|
||||||
|
|
||||||
|
if not team:
|
||||||
|
raise HTTPException(status_code=404, detail="Team not found")
|
||||||
|
|
||||||
|
# Check if user is admin
|
||||||
|
membership = db.query(TeamMembership).filter_by(
|
||||||
|
user_id=user.id,
|
||||||
|
team_id=team.id,
|
||||||
|
is_admin=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not membership:
|
||||||
|
raise HTTPException(status_code=403, detail="You don't have permission to update this team")
|
||||||
|
|
||||||
|
# Update team details
|
||||||
|
team.name = team_name
|
||||||
|
team.is_public = is_public
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return RedirectResponse(f"/teams/{team_id}", status_code=303)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Utility module to help debug session problems.
|
||||||
|
"""
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
def print_session_debug(request: Request):
|
||||||
|
"""Print debug information about the request session."""
|
||||||
|
print("\n=== SESSION DEBUG INFO ===")
|
||||||
|
print(f"Request URL: {request.url}")
|
||||||
|
print(f"Session in scope: {'session' in request.scope}")
|
||||||
|
|
||||||
|
if 'session' in request.scope:
|
||||||
|
print("Session contents:")
|
||||||
|
try:
|
||||||
|
for key, value in request.session.items():
|
||||||
|
print(f" {key}: {value}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error accessing session items: {e}")
|
||||||
|
else:
|
||||||
|
print("No session found in request scope")
|
||||||
|
|
||||||
|
print("Headers:")
|
||||||
|
for name, value in request.headers.items():
|
||||||
|
if name.lower() in ('cookie', 'set-cookie'):
|
||||||
|
print(f" {name}: [REDACTED]") # Don't print actual cookie values
|
||||||
|
else:
|
||||||
|
print(f" {name}: {value}")
|
||||||
|
|
||||||
|
print("=========================\n")
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
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"
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$$MYSQL_ROOT_PASSWORD"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
container_name: pubquiz_app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DB_HOST: "db"
|
||||||
|
DB_PORT: "3306"
|
||||||
|
DB_NAME: "pubquiz_db"
|
||||||
|
DB_USER: "pubquiz_user"
|
||||||
|
DB_PASS: "pubquiz_pass"
|
||||||
|
# Session configuration
|
||||||
|
SECRET_KEY: "a-stronger-secret-key-for-sessions-32chars"
|
||||||
|
DEBUG: "True"
|
||||||
|
SESSION_MAX_AGE: "86400" # 24 hours
|
||||||
|
COOKIE_SECURE: "False" # Set to True in production with HTTPS
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- ./:/app:delegated
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
fastapi
|
||||||
|
uvicorn[standard]
|
||||||
|
SQLAlchemy
|
||||||
|
mysqlclient
|
||||||
|
Jinja2
|
||||||
|
python-multipart
|
||||||
|
passlib[bcrypt]
|
||||||
|
qrcode
|
||||||
|
email-validator
|
||||||
|
pillow
|
||||||
|
python-jose[cryptography]
|
||||||
|
itsdangerous
|
||||||
|
bcrypt==4.0.1
|
||||||
|
flask-session==0.5.0
|
||||||
Reference in New Issue
Block a user