diff --git a/app/auth/utils.py b/app/auth/utils.py
new file mode 100644
index 0000000..f5f2bf7
--- /dev/null
+++ b/app/auth/utils.py
@@ -0,0 +1,26 @@
+from fastapi import Request
+from sqlalchemy.orm import Session
+from ..models import User
+import logging
+
+logger = logging.getLogger(__name__)
+
+def get_current_user_from_session(request: Request, db: Session):
+ """Get the current user from the session."""
+ try:
+ if hasattr(request.state, "user") and request.state.user is not None:
+ # User is already in request state, return it
+ return request.state.user
+
+ if hasattr(request, "session"):
+ user_id = request.session.get("user_id")
+ if user_id:
+ user = db.query(User).filter(User.id == user_id).first()
+ if user:
+ # Set it in the request state for future use
+ request.state.user = user
+ return user
+ except Exception as e:
+ logger.error(f"Error retrieving user from session: {str(e)}")
+
+ return None
diff --git a/app/config.py b/app/config.py
new file mode 100644
index 0000000..945573c
--- /dev/null
+++ b/app/config.py
@@ -0,0 +1,55 @@
+import os
+from datetime import timedelta
+from pydantic import BaseSettings, PostgresDsn, EmailStr
+import secrets
+
+class Settings(BaseSettings):
+ # Base URL
+ BASE_URL: str = os.getenv("LEAGUELEDGER_BASE_URL", "http://localhost:8000")
+
+ # Database settings
+ DB_HOST: str = os.getenv("DB_HOST", "localhost")
+ DB_PORT: str = os.getenv("DB_PORT", "3306")
+ DB_NAME: str = os.getenv("DB_NAME", "leagueledger")
+ DB_USER: str = os.getenv("DB_USER", "root")
+ DB_PASS: str = os.getenv("DB_PASS", "")
+
+ # MySQL connection string
+ DATABASE_URL: str = f"mysql+mysqldb://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
+
+ # Security settings
+ SECRET_KEY: str = os.getenv("SECRET_KEY", secrets.token_urlsafe(32))
+ ALGORITHM: str = "HS256"
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 1 day
+
+ # Session settings
+ SESSION_MAX_AGE: int = 86400 # 1 day in seconds
+
+ # Email settings
+ MAIL_USERNAME: str = os.getenv("MAIL_USERNAME", "")
+ MAIL_PASSWORD: str = os.getenv("MAIL_PASSWORD", "")
+ MAIL_FROM: str = os.getenv("MAIL_FROM", "noreply@leagueledger.net")
+ MAIL_FROM_NAME: str = os.getenv("MAIL_FROM_NAME", "LeagueLedger")
+ MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587"))
+ MAIL_SERVER: str = os.getenv("MAIL_SERVER", "localhost")
+ MAIL_STARTTLS: bool = os.getenv("MAIL_STARTTLS", "True").lower() == "true"
+ MAIL_SSL_TLS: bool = os.getenv("MAIL_SSL_TLS", "False").lower() == "true"
+ MAIL_USE_CREDENTIALS: bool = os.getenv("MAIL_USE_CREDENTIALS", "True").lower() == "true"
+ MAIL_VALIDATE_CERTS: bool = os.getenv("MAIL_VALIDATE_CERTS", "True").lower() == "true"
+
+ # OAuth Settings
+ GOOGLE_CLIENT_ID: str = os.getenv("GOOGLE_CLIENT_ID", "")
+ GOOGLE_CLIENT_SECRET: str = os.getenv("GOOGLE_CLIENT_SECRET", "")
+
+ # QR Code settings
+ QR_CODE_BOX_SIZE: int = 10
+ QR_CODE_BORDER: int = 4
+
+ # Team settings
+ MAX_TEAM_SIZE: int = 10
+
+ class Config:
+ env_file = ".env"
+ env_file_encoding = "utf-8"
+
+settings = Settings()
diff --git a/app/main.py b/app/main.py
index 572d244..28bdecb 100644
--- a/app/main.py
+++ b/app/main.py
@@ -3,6 +3,7 @@ from fastapi import FastAPI, Request, Depends, Form
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
+from fastapi.middleware.cors import CORSMiddleware
from pathlib import Path
import os
from starlette.middleware.sessions import SessionMiddleware
@@ -13,7 +14,7 @@ import contextlib
from .db import init_db, engine, get_db
from . import models
from .templates_config import templates
-from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth
+from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience
from .db_init import seed_db
from .db_migrations import apply_migrations
@@ -27,9 +28,18 @@ load_dotenv()
# Create the FastAPI application
app = FastAPI(title="LeagueLedger")
+# CORS middleware
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"], # In production, replace with specific origins
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
# Add SessionMiddleware with a secure secret key
-# Must be added first before other middleware to ensure it's available
-app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET_KEY", "your-very-secret-session-key"))
+SECRET_KEY = os.getenv("SECRET_KEY", "a-very-secure-secret-key-for-development")
+app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
# Initialize database on startup
@app.on_event("startup")
@@ -69,68 +79,58 @@ templates = Jinja2Templates(directory="app/templates")
# User context middleware
@app.middleware("http")
async def add_template_globals(request: Request, call_next):
- """Add template globals"""
+ """Add global variables to all templates."""
try:
- # Update template globals for all templates
- user = None
- if hasattr(request, "session") and "user_id" in request.session and request.session.get("is_authenticated"):
- # Mock user object - in a real app, you'd fetch this from the database
- user = {
- "id": request.session["user_id"],
- "username": request.session.get("username", "User"),
- "is_admin": request.session.get("is_admin", False)
- }
- templates.env.globals["current_user"] = user
+ # Set user in request state for templates
+ if hasattr(request, "session"):
+ user_id = request.session.get("user_id")
+ if user_id:
+ # Get a database session
+ from sqlalchemy.orm import Session
+ from .db import SessionLocal
+ from .models import User
+
+ db = SessionLocal()
+ try:
+ # Fetch actual user from database
+ user = db.query(User).filter(User.id == user_id).first()
+ if user:
+ request.state.user = user
+ else:
+ request.state.user = None
+ finally:
+ db.close()
+ else:
+ request.state.user = None
+ else:
+ request.state.user = None
except Exception as e:
+ # Log error but continue processing
logger.error(f"Error setting template globals: {str(e)}")
-
- # Process the request
+ request.state.user = None
+
+ # Continue with request
response = await call_next(request)
return response
-@app.get("/", response_class=HTMLResponse)
-async def read_root(request: Request):
- user = None
- if "user_id" in request.session and request.session.get("is_authenticated"):
- user = {
- "id": request.session["user_id"],
- "username": request.session.get("username", "User"),
- "is_admin": request.session.get("is_admin", False)
- }
+# Handle exceptions
+@app.exception_handler(404)
+async def not_found_exception_handler(request: Request, exc):
+ """Handle 404 errors with a custom template."""
return templates.TemplateResponse(
- "index.html",
- {"request": request, "user": user}
+ "error.html",
+ {"request": request, "error": "Page not found"},
+ status_code=404
)
# Routers
app.include_router(pages.router, tags=["Pages"]) # Pages router for index and static pages
-app.include_router(auth.router) # Include the auth router
+app.include_router(auth.router, prefix="/auth", tags=["auth"]) # Include the auth router
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(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(leaderboard.router, prefix="/leaderboard", tags=["leaderboard"])
app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"])
app.include_router(static.router, tags=["Static"]) # Include the static router
-
-# Add a direct route for /scan that redirects to /dashboard/scan
-@app.get("/scan")
-async def scan_redirect():
- return RedirectResponse("/dashboard/scan", status_code=303)
-
-# Add convenience routes for auth paths
-@app.get("/login")
-async def login_redirect():
- return RedirectResponse("/auth/login", status_code=303)
-
-@app.get("/register")
-async def register_redirect():
- return RedirectResponse("/auth/register", status_code=303)
-
-@app.get("/profile")
-async def profile_redirect():
- return RedirectResponse("/auth/profile", status_code=303)
-
-@app.get("/logout")
-async def logout_redirect():
- return RedirectResponse("/auth/logout", status_code=303)
+app.include_router(convenience.router, tags=["Convenience"]) # Include convenience routes
diff --git a/app/models.py b/app/models.py
index ba5194b..7810f2c 100644
--- a/app/models.py
+++ b/app/models.py
@@ -1,8 +1,9 @@
#!/usr/bin/env python3
-from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float
+from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float, UniqueConstraint
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from sqlalchemy.ext.declarative import declarative_base
+from datetime import datetime
# This Base should be the single source of truth
Base = declarative_base()
@@ -33,7 +34,6 @@ class User(Base):
# Relationships
memberships = relationship("TeamMembership", back_populates="user")
- teams = relationship("TeamMember", back_populates="user")
points = relationship("UserPoints", back_populates="user")
events_attended = relationship("EventAttendee", back_populates="user")
owned_teams = relationship("Team", back_populates="owner")
@@ -61,41 +61,51 @@ class Team(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text, nullable=True)
+ logo_url = Column(String(255), nullable=True) # Add logo URL field
is_public = Column(Boolean, default=False) # For team privacy setting
- created_at = Column(DateTime, server_default=func.now()) # For team founded date
- updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
+ is_open = Column(Boolean, default=False) # Whether anyone can join without approval
+ is_active = Column(Boolean, default=True) # Add this column to fix the error
+ created_at = Column(DateTime, default=datetime.utcnow) # For team founded date
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
# Relationships
- memberships = relationship("TeamMembership", back_populates="team")
- members = relationship("TeamMember", back_populates="team")
+ members = relationship("TeamMembership", back_populates="team", cascade="all, delete-orphan")
owner = relationship("User", back_populates="owned_teams")
+class TeamJoinRequest(Base):
+ __tablename__ = "team_join_requests"
+ id = Column(Integer, primary_key=True, index=True)
+ team_id = Column(Integer, ForeignKey("teams.id"), nullable=False)
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ message = Column(String(500), nullable=True) # Specified length for VARCHAR
+ status = Column(String(20), default="pending") # pending, approved, denied
+ request_token = Column(String(100), unique=True, nullable=False, index=True)
+ created_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+ # Relationships
+ team = relationship("Team")
+ user = relationship("User")
+
+ __table_args__ = (UniqueConstraint('team_id', 'user_id', 'status', name='_team_user_request_status_uc'),)
+
+
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"))
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
+ team_id = Column(Integer, ForeignKey("teams.id"), nullable=False)
is_admin = Column(Boolean, default=False)
+ is_captain = Column(Boolean, default=False) # Added captain status
joined_at = Column(DateTime, server_default=func.now())
user = relationship("User", back_populates="memberships")
- team = relationship("Team", back_populates="memberships")
-
-
-class TeamMember(Base):
- __tablename__ = "team_members"
- id = Column(Integer, primary_key=True, index=True)
- user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
- team_id = Column(Integer, ForeignKey("teams.id"), nullable=False)
- is_captain = Column(Boolean, default=False)
- joined_at = Column(DateTime, server_default=func.now())
-
- # Relationships
- user = relationship("User", back_populates="teams")
team = relationship("Team", back_populates="members")
+ __table_args__ = (UniqueConstraint('user_id', 'team_id', name='_user_team_uc'),)
+
class QRSet(Base):
"""A set of related QR codes, such as codes for different placements in a quiz"""
diff --git a/app/templates/email/join_request_response.html b/app/templates/email/join_request_response.html
new file mode 100644
index 0000000..43a9dfe
--- /dev/null
+++ b/app/templates/email/join_request_response.html
@@ -0,0 +1,74 @@
+
+
+
+
+
+ Team Join Request Response - LeagueLedger
+
+
+
+
+
+
+
Hello {{ username }},
+
+ {% if is_approved %}
+
Good news! Your request to join {{ team_name }} has been approved. You are now a member of the team.
+ {% else %}
+
We regret to inform you that your request to join {{ team_name }} has been denied.
+ {% endif %}
+
+
You can view your teams by visiting your dashboard:
+
+
+ Go to Dashboard
+
+
+
Best regards,
The LeagueLedger Team
+
+
+
+
+
diff --git a/app/templates/email/password_reset.html b/app/templates/email/password_reset.html
index 99684fb..326de35 100644
--- a/app/templates/email/password_reset.html
+++ b/app/templates/email/password_reset.html
@@ -45,27 +45,25 @@
Hello {{ username }},
-
We received a request to reset your password for your LeagueLedger account. If you didn't make this request, you can safely ignore this email.
+
We received a request to reset your password. If you didn't make this request, you can safely ignore this email.
-
To reset your password, please click the button below:
+
To reset your password, click the button below:
-
- Reset Password
-
+
-
Or copy and paste this link into your browser:
-
{{ reset_link }}
+
Or you can copy and paste this link into your browser:
+
{{ reset_url }}
This link will expire in 24 hours.
-
If you have any questions, please contact us at {{ support_email }}
-
Best regards,
The LeagueLedger Team
diff --git a/app/templates/email/team_join_request.html b/app/templates/email/team_join_request.html
new file mode 100644
index 0000000..da50b37
--- /dev/null
+++ b/app/templates/email/team_join_request.html
@@ -0,0 +1,86 @@
+
+
+
+
+
+ Team Join Request - LeagueLedger
+
+
+
+
+
+
+
Hello {{ captain_name }},
+
+
{{ requester_name }} has requested to join your team {{ team_name }}.
+
+ {% if message %}
+
Message from {{ requester_name }}:
"{{ message }}"
+ {% endif %}
+
+
You can approve or deny this request by clicking one of the buttons below:
+
+
+
+
Or you can copy and paste one of these links into your browser:
+
Approve: {{ approve_url }}
+
Deny: {{ deny_url }}
+
+
Best regards,
The LeagueLedger Team
+
+
+
+
+
diff --git a/app/templates/error.html b/app/templates/error.html
index 3c9c510..b3b817a 100644
--- a/app/templates/error.html
+++ b/app/templates/error.html
@@ -1,22 +1,29 @@
{% extends "base.html" %}
+
{% block content %}
-
-
-
-
+
+
+
+
-
Oops! Something went wrong
-
{{ error }}
-
{% endblock %}
diff --git a/app/templates/team_detail.html b/app/templates/team_detail.html
index 82448c8..1cee6e6 100644
--- a/app/templates/team_detail.html
+++ b/app/templates/team_detail.html
@@ -21,11 +21,23 @@
Login to Join
{% elif not is_team_member %}
-
+
+
Join This Team
+
+ {% if is_open %}
+
This is an open team. You can join immediately.
+
+ {% else %}
+
This is a closed team. You need to request to join and be approved by a team captain.
+
+ Request to Join
+
+ {% endif %}
+
{% else %}