Implement team join request functionality with views and actions

- Added join_requests.html template for displaying pending join requests.
- Created join_team.html template for users to submit join requests.
- Implemented request_processed.html template to show the result of join request processing.
- Developed authentication utilities for user session management.
- Introduced convenience redirects for common URL patterns.
- Established team management routes and actions for creating, editing, and joining teams.
- Added functionality for approving and denying join requests with email notifications.
- Enhanced team views to include user permissions and team member details.
- Implemented utility functions for team-related operations such as calculating total points and team rank.
This commit is contained in:
Christian Krakau-Louis
2025-04-14 17:00:57 +02:00
parent 5cf3f944b1
commit 7323c12168
26 changed files with 2138 additions and 319 deletions
+26
View File
@@ -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
+55
View File
@@ -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()
+52 -52
View File
@@ -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
+31 -21
View File
@@ -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"""
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Team Join Request Response - LeagueLedger</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #2D7738;
padding: 20px;
text-align: center;
color: white;
border-radius: 5px 5px 0 0;
}
.content {
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
}
.button {
display: inline-block;
background-color: #2D7738;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 5px;
margin: 20px 0;
}
.footer {
margin-top: 20px;
font-size: 12px;
text-align: center;
color: #777;
}
</style>
</head>
<body>
<div class="header">
<h1>Team Join Request {{ "Approved" if is_approved else "Denied" }}</h1>
</div>
<div class="content">
<p>Hello {{ username }},</p>
{% if is_approved %}
<p>Good news! Your request to join <strong>{{ team_name }}</strong> has been approved. You are now a member of the team.</p>
{% else %}
<p>We regret to inform you that your request to join <strong>{{ team_name }}</strong> has been denied.</p>
{% endif %}
<p>You can view your teams by visiting your dashboard:</p>
<p style="text-align: center;">
<a href="{{ base_url }}/dashboard" class="button">Go to Dashboard</a>
</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="footer">
<p>© LeagueLedger. All rights reserved.</p>
<p>This is an automated message, please do not reply to this email.</p>
</div>
</body>
</html>
+8 -10
View File
@@ -45,27 +45,25 @@
</head>
<body>
<div class="header">
<h1>LeagueLedger Password Reset</h1>
<h1>Password Reset</h1>
</div>
<div class="content">
<p>Hello {{ username }},</p>
<p>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.</p>
<p>We received a request to reset your password. If you didn't make this request, you can safely ignore this email.</p>
<p>To reset your password, please click the button below:</p>
<p>To reset your password, click the button below:</p>
<p style="text-align: center;">
<a href="{{ reset_link }}" class="button">Reset Password</a>
</p>
<div style="margin: 30px 0; text-align: center;">
<a href="{{ reset_url }}" class="button">Reset Password</a>
</div>
<p>Or copy and paste this link into your browser:</p>
<p>{{ reset_link }}</p>
<p>Or you can copy and paste this link into your browser:</p>
<p>{{ reset_url }}</p>
<p>This link will expire in 24 hours.</p>
<p>If you have any questions, please contact us at {{ support_email }}</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Team Join Request - LeagueLedger</title>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #2D7738;
padding: 20px;
text-align: center;
color: white;
border-radius: 5px 5px 0 0;
}
.content {
padding: 20px;
border: 1px solid #ddd;
border-top: none;
border-radius: 0 0 5px 5px;
}
.button {
display: inline-block;
background-color: #2D7738;
color: white;
text-decoration: none;
padding: 10px 20px;
border-radius: 5px;
margin: 20px 0;
}
.button.approve {
background-color: #4CAF50;
}
.button.deny {
background-color: #f44336;
margin-left: 10px;
}
.footer {
margin-top: 20px;
font-size: 12px;
text-align: center;
color: #777;
}
</style>
</head>
<body>
<div class="header">
<h1>Team Join Request</h1>
</div>
<div class="content">
<p>Hello {{ captain_name }},</p>
<p><strong>{{ requester_name }}</strong> has requested to join your team <strong>{{ team_name }}</strong>.</p>
{% if message %}
<p>Message from {{ requester_name }}:<br><em>"{{ message }}"</em></p>
{% endif %}
<p>You can approve or deny this request by clicking one of the buttons below:</p>
<div style="margin: 30px 0; text-align: center;">
<a href="{{ approve_url }}" class="button approve">Approve Request</a>
<a href="{{ deny_url }}" class="button deny">Deny Request</a>
</div>
<p>Or you can copy and paste one of these links into your browser:</p>
<p>Approve: {{ approve_url }}</p>
<p>Deny: {{ deny_url }}</p>
<p>Best regards,<br>The LeagueLedger Team</p>
</div>
<div class="footer">
<p>© LeagueLedger. All rights reserved.</p>
<p>This is an automated message, please do not reply to this email.</p>
</div>
</body>
</html>
+21 -14
View File
@@ -1,22 +1,29 @@
{% extends "base.html" %}
{% block content %}
<div class="flex flex-col items-center justify-center min-h-[60vh] px-4 py-12">
<div class="text-center">
<div class="mb-6">
<svg class="mx-auto h-16 w-16 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div class="max-w-md mx-auto my-12">
<div class="bg-white p-8 rounded-lg shadow-md text-center">
<div class="text-red-500 text-5xl mb-6">
<i class="fas fa-exclamation-circle"></i>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">Oops! Something went wrong</h1>
<p class="text-gray-600 mb-6">{{ error }}</p>
<div class="flex justify-center">
<a href="/" class="bg-irish-green hover:bg-opacity-90 text-white font-bold py-2 px-6 rounded-md mr-4">
Go Home
<h1 class="text-2xl font-bold mb-4">{{ error|default("An error occurred", true) }}</h1>
{% if details %}
<p class="text-gray-600 mb-6">{{ details }}</p>
{% endif %}
<div class="mt-8">
<a href="/" class="inline-block bg-irish-green text-white py-2 px-6 rounded-md hover:bg-opacity-90 transition">
Return to Home
</a>
<button onclick="window.history.back()" class="border border-irish-green text-irish-green hover:bg-gray-100 font-bold py-2 px-6 rounded-md">
Go Back
</button>
</div>
{% if debug_info %}
<div class="mt-8 p-4 bg-gray-100 rounded text-left">
<h3 class="font-semibold mb-2">Debug Information:</h3>
<pre class="text-xs overflow-auto">{{ debug_info }}</pre>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+17 -5
View File
@@ -21,11 +21,23 @@
Login to Join
</a>
{% elif not is_team_member %}
<form action="/teams/join/{{ team.id }}" method="post" class="inline">
<button type="submit" class="bg-white text-irish-green font-medium py-2 px-4 rounded-md hover:bg-opacity-90">
Join Team
</button>
</form>
<div class="my-4 p-4 bg-gray-100 rounded">
<h3 class="text-xl font-semibold mb-2">Join This Team</h3>
{% if is_open %}
<p class="mb-3">This is an open team. You can join immediately.</p>
<form action="/teams/join/{{ team.id }}" method="post">
<button type="submit" class="bg-irish-green text-white py-2 px-6 rounded-md hover:bg-opacity-90 transition">
Join Team
</button>
</form>
{% else %}
<p class="mb-3">This is a closed team. You need to request to join and be approved by a team captain.</p>
<a href="/teams/{{ team.id }}/join" class="inline-block bg-irish-green text-white py-2 px-6 rounded-md hover:bg-opacity-90 transition">
Request to Join
</a>
{% endif %}
</div>
{% else %}
<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
+17
View File
@@ -0,0 +1,17 @@
<!-- Add this to the form, right before the submit button -->
<div class="mb-6">
<div class="flex items-center">
<input
type="checkbox"
id="is_open"
name="is_open"
class="h-4 w-4 text-irish-green focus:ring-irish-green border-gray-300 rounded"
>
<label for="is_open" class="ml-2 block text-gray-700">
Open Team (anyone can join without approval)
</label>
</div>
<p class="mt-1 text-sm text-gray-500">
If unchecked, users will need to request to join and be approved by a captain.
</p>
</div>
+18
View File
@@ -0,0 +1,18 @@
<!-- Add this to the form, right before the submit button -->
<div class="mb-6">
<div class="flex items-center">
<input
type="checkbox"
id="is_open"
name="is_open"
{% if team.is_open %}checked{% endif %}
class="h-4 w-4 text-irish-green focus:ring-irish-green border-gray-300 rounded"
>
<label for="is_open" class="ml-2 block text-gray-700">
Open Team (anyone can join without approval)
</label>
</div>
<p class="mt-1 text-sm text-gray-500">
If unchecked, users will need to request to join and be approved by a captain.
</p>
</div>
+90
View File
@@ -0,0 +1,90 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-4xl mx-auto my-8">
<div class="bg-white p-8 rounded-lg shadow-md">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Join Requests - {{ team.name }}</h1>
<a href="/teams/{{ team.id }}" class="text-irish-green hover:underline">Back to Team</a>
</div>
{% if request.query_params.get('message') %}
<div class="bg-green-100 border-l-4 border-green-500 text-green-700 p-4 mb-6" role="alert">
<p>{{ request.query_params.get('message') }}</p>
</div>
{% endif %}
{% if requests|length > 0 %}
<div class="bg-blue-50 p-4 rounded-md mb-6">
<p class="text-blue-800">
<i class="fas fa-info-circle mr-2"></i>
You have {{ requests|length }} pending join request{% if requests|length > 1 %}s{% endif %}.
</p>
</div>
<div class="overflow-x-auto">
<table class="min-w-full bg-white border border-gray-200">
<thead>
<tr>
<th class="py-3 px-4 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b">User</th>
<th class="py-3 px-4 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b">Date</th>
<th class="py-3 px-4 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b">Message</th>
<th class="py-3 px-4 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-b">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
{% for item in requests %}
<tr>
<td class="py-4 px-4 whitespace-nowrap">
<div class="flex items-center">
{% if item.user.picture %}
<img src="{{ item.user.picture }}" alt="{{ item.user.username }}" class="w-8 h-8 rounded-full mr-3">
{% else %}
<div class="w-8 h-8 rounded-full bg-irish-green flex items-center justify-center text-white mr-3">
<span>{{ item.user.username[0]|upper }}</span>
</div>
{% endif %}
<div>
<div class="text-sm font-medium text-gray-900">{{ item.user.username }}</div>
<div class="text-sm text-gray-500">{{ item.user.email }}</div>
</div>
</div>
</td>
<td class="py-4 px-4 whitespace-nowrap text-sm text-gray-500">
{{ item.request.created_at.strftime('%Y-%m-%d %H:%M') }}
</td>
<td class="py-4 px-4 text-sm text-gray-500 max-w-xs truncate">
{% if item.request.message %}
{{ item.request.message }}
{% else %}
<span class="text-gray-400 italic">No message</span>
{% endif %}
</td>
<td class="py-4 px-4 whitespace-nowrap text-sm font-medium">
<form action="/teams/{{ team.id }}/requests/{{ item.request.id }}" method="post" class="inline-block">
<input type="hidden" name="decision" value="approve">
<button type="submit" class="bg-green-500 hover:bg-green-600 text-white py-1 px-3 rounded mr-2">
Approve
</button>
</form>
<form action="/teams/{{ team.id }}/requests/{{ item.request.id }}" method="post" class="inline-block">
<input type="hidden" name="decision" value="deny">
<button type="submit" class="bg-red-500 hover:bg-red-600 text-white py-1 px-3 rounded">
Deny
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-12">
<i class="fas fa-inbox text-gray-300 text-5xl mb-4"></i>
<p class="text-xl text-gray-500">No pending join requests</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+56
View File
@@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block content %}
<div class="max-w-md mx-auto my-8">
<div class="bg-white p-8 rounded-lg shadow-md">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Join Request - {{ team.name }}</h1>
<a href="/teams/{{ team.id }}" class="text-irish-green hover:underline">Back to Team</a>
</div>
{% if error %}
<div class="bg-red-100 border-l-4 border-red-500 text-red-700 p-4 mb-6" role="alert">
<p>{{ error }}</p>
</div>
{% endif %}
{% if is_open %}
<div class="text-center">
<div class="bg-green-100 p-4 rounded-md mb-6">
<p class="text-green-800">
<i class="fas fa-info-circle mr-2"></i>
This is an open team. You can join immediately without approval.
</p>
</div>
<form action="/teams/join/{{ team.id }}" method="post">
<button type="submit" class="bg-irish-green text-white py-2 px-6 rounded-md hover:bg-opacity-90 transition w-full">
Join Now
</button>
</form>
</div>
{% else %}
<div class="mb-6">
<p class="text-gray-700 mb-4">You're requesting to join <strong>{{ team.name }}</strong>. Your request will need to be approved by a team captain.</p>
<form action="/teams/{{ team.id }}/join" method="post" class="space-y-4">
<div>
<label for="message" class="block text-sm font-medium text-gray-700 mb-1">Message (Optional)</label>
<textarea
id="message"
name="message"
rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-irish-green"
placeholder="Tell the team why you'd like to join..."
></textarea>
</div>
<button type="submit" class="w-full bg-irish-green text-white py-2 px-6 rounded-md hover:bg-opacity-90 transition">
Send Join Request
</button>
</form>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}Request Processed{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row">
<div class="col-md-8 offset-md-2">
<div class="card">
<div class="card-header">
<h3>Team Join Request Processed</h3>
</div>
<div class="card-body">
<div class="alert alert-success">
{{ message }}
</div>
<div class="text-center mt-4">
<a href="/teams/{{ team_id }}" class="btn btn-primary">Go to Team Page</a>
<a href="/teams" class="btn btn-outline-secondary">Back to Teams List</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+3
View File
@@ -1 +1,4 @@
"""
Utility functions for LeagueLedger.
"""
# Utils package initialization
+132
View File
@@ -0,0 +1,132 @@
"""
Authentication utilities for LeagueLedger.
"""
from typing import Optional
from fastapi import Request, Depends
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import User, TeamMembership
async def get_current_user(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
"""
Get the current authenticated user from the session.
Args:
request: The FastAPI request object
db: SQLAlchemy database session
Returns:
User object if authenticated, None otherwise
"""
user_id = request.session.get("user_id")
if not user_id:
return None
# Fetch the user from the database
user = db.query(User).filter(User.id == user_id).first()
if not user:
# If user doesn't exist in database but has a session, clear the session
request.session.clear()
return None
return user
async def is_team_captain(
team_id: int,
user: Optional[User] = None,
request: Optional[Request] = None,
db: Session = Depends(get_db)
) -> bool:
"""
Check if the current user is a captain of the specified team.
Args:
team_id: ID of the team to check
user: Optional pre-loaded user object
request: Optional FastAPI request object (used if user is not provided)
db: SQLAlchemy database session
Returns:
True if the user is a team captain, False otherwise
"""
if not user and request:
user = await get_current_user(request, db)
if not user:
return False
# Check if the user is a captain of the team
is_captain = db.query(TeamMembership).filter(
TeamMembership.team_id == team_id,
TeamMembership.user_id == user.id,
TeamMembership.role == "captain"
).first()
return bool(is_captain)
async def is_admin(request: Request, db: Session = Depends(get_db)) -> bool:
"""
Check if the current user is an admin.
Args:
request: FastAPI request object
db: SQLAlchemy database session
Returns:
True if the user is an admin, False otherwise
"""
user = await get_current_user(request, db)
if not user:
return False
return user.is_admin
async def requires_login(request: Request, db: Session = Depends(get_db)) -> Optional[User]:
"""
Dependency to ensure the user is logged in.
Args:
request: FastAPI request object
db: SQLAlchemy database session
Returns:
User object if authenticated, raises HTTPException otherwise
"""
from fastapi import HTTPException, status
user = await get_current_user(request, db)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
headers={"WWW-Authenticate": "Bearer"},
)
return user
async def requires_admin(request: Request, db: Session = Depends(get_db)) -> User:
"""
Dependency to ensure the user is an admin.
Args:
request: FastAPI request object
db: SQLAlchemy database session
Returns:
User object if authenticated and is admin, raises HTTPException otherwise
"""
from fastapi import HTTPException, status
user = await get_current_user(request, db)
if not user or not user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)
return user
+198 -169
View File
@@ -8,192 +8,221 @@ from fastapi import BackgroundTasks
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig, MessageType
from pydantic import EmailStr
from dotenv import load_dotenv
from jinja2 import Environment, FileSystemLoader
import logging
# Setup logging
logger = logging.getLogger(__name__)
# Load environment variables if not already loaded
load_dotenv()
# Get mail settings from environment variables or use default values
MAIL_USERNAME = os.getenv("MAIL_USERNAME", "")
MAIL_PASSWORD = os.getenv("MAIL_PASSWORD", "")
MAIL_FROM = os.getenv("MAIL_FROM", "noreply@leagueledger.com")
MAIL_SERVER = os.getenv("MAIL_SERVER", "smtp.example.com")
MAIL_PORT = int(os.getenv("MAIL_PORT", "587"))
MAIL_FROM_NAME = os.getenv("MAIL_FROM_NAME", "LeagueLedger")
MAIL_STARTTLS = os.getenv("MAIL_STARTTLS", "True").lower() == "true"
MAIL_SSL_TLS = os.getenv("MAIL_SSL_TLS", "False").lower() == "true"
USE_CREDENTIALS = os.getenv("MAIL_USE_CREDENTIALS", "True").lower() == "true"
VALIDATE_CERTS = os.getenv("MAIL_VALIDATE_CERTS", "True").lower() == "true"
APP_BASE_URL = os.getenv("LEAGUELEDGER_BASE_URL", os.getenv("APP_BASE_URL", "http://localhost:8000"))
# Configure Jinja2 for email templates
template_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates")
env = Environment(loader=FileSystemLoader(template_dir))
# Configure email connection
mail_config = ConnectionConfig(
MAIL_USERNAME=os.getenv("MAIL_USERNAME"),
MAIL_PASSWORD=os.getenv("MAIL_PASSWORD"),
MAIL_FROM=os.getenv("MAIL_FROM"),
MAIL_PORT=int(os.getenv("MAIL_PORT", 587)),
MAIL_SERVER=os.getenv("MAIL_SERVER"),
MAIL_FROM_NAME=os.getenv("MAIL_FROM_NAME", "LeagueLedger"),
MAIL_STARTTLS=os.getenv("MAIL_STARTTLS", "True").lower() in ("true", "1", "t"),
MAIL_SSL_TLS=os.getenv("MAIL_SSL_TLS", "False").lower() in ("true", "1", "t"),
USE_CREDENTIALS=os.getenv("MAIL_USE_CREDENTIALS", "True").lower() in ("true", "1", "t"),
VALIDATE_CERTS=os.getenv("MAIL_VALIDATE_CERTS", "True").lower() in ("true", "1", "t"),
TEMPLATE_FOLDER=Path(__file__).parent.parent / 'templates' / 'email',
conf = ConnectionConfig(
MAIL_USERNAME=MAIL_USERNAME,
MAIL_PASSWORD=MAIL_PASSWORD,
MAIL_FROM=MAIL_FROM,
MAIL_PORT=MAIL_PORT,
MAIL_SERVER=MAIL_SERVER,
MAIL_FROM_NAME=MAIL_FROM_NAME,
MAIL_STARTTLS=MAIL_STARTTLS,
MAIL_SSL_TLS=MAIL_SSL_TLS,
USE_CREDENTIALS=USE_CREDENTIALS,
VALIDATE_CERTS=VALIDATE_CERTS
)
# Create FastMail instance
mail = FastMail(mail_config)
async def send_email(
recipients: List[EmailStr],
email_to: List[EmailStr],
subject: str,
body: str,
template_name: Optional[str] = None,
template_body: Optional[Dict[str, Any]] = None,
background_tasks: Optional[BackgroundTasks] = None,
subtype: MessageType = MessageType.html,
cc: Optional[List[EmailStr]] = None,
bcc: Optional[List[EmailStr]] = None,
attachments: Optional[List] = None,
headers: Optional[Dict[str, str]] = None,
) -> None:
"""
Send an email using FastAPI-Mail
Args:
recipients: List of recipient email addresses
subject: Email subject
body: Email body content (used if template_name is None)
template_name: Optional name of the template file in the TEMPLATE_FOLDER
template_body: Optional dictionary of template variables
background_tasks: Optional BackgroundTasks for sending email in background
subtype: Message type (html or plain)
cc: Optional list of CC recipients
bcc: Optional list of BCC recipients
attachments: Optional list of attachments
headers: Optional custom email headers
"""
# Create message schema with empty lists for optional parameters to prevent validation errors
message = MessageSchema(
subject=subject,
recipients=recipients,
body=body if not template_name else None,
template_body=template_body,
subtype=subtype,
cc=cc or [], # Use empty list if None
bcc=bcc or [], # Use empty list if None
attachments=attachments or [], # Use empty list if None
headers=headers,
)
# Send email
html_content: str,
background_tasks: BackgroundTasks
):
"""Generic function to send emails"""
try:
if background_tasks:
if template_name:
background_tasks.add_task(mail.send_message, message, template_name=template_name)
else:
background_tasks.add_task(mail.send_message, message)
else:
if template_name:
await mail.send_message(message, template_name=template_name)
else:
await mail.send_message(message)
message = MessageSchema(
subject=subject,
recipients=[email_to] if isinstance(email_to, str) else email_to,
body=html_content,
subtype="html"
)
fm = FastMail(conf)
# Send email in the background to avoid blocking the main thread
background_tasks.add_task(fm.send_message, message)
logger.info(f"Email queued for sending to {email_to}")
return True
except Exception as e:
# Log the error but don't crash the application
print(f"Error sending email: {str(e)}")
# In a production app, you would use a proper logging system
logger.error(f"Failed to send email: {str(e)}")
return False
async def send_password_reset_email(
email: EmailStr,
email_to: str,
username: str,
reset_token: str,
background_tasks: Optional[BackgroundTasks] = None,
) -> None:
"""
Send password reset email
Args:
email: Recipient email address
username: User's username
reset_token: Password reset token
background_tasks: Optional BackgroundTasks for sending in background
"""
# Base URL for the application (should be configured in environment vars)
base_url = os.getenv("LEAGUELEDGER_BASE_URL", "http://localhost:8000")
reset_link = f"{base_url}/auth/reset-password?token={reset_token}"
# Template data
template_data = {
"username": username,
"reset_link": reset_link,
"support_email": os.getenv("MAIL_FROM", "support@leagueledger.net"),
"base_url": base_url,
}
# Send email
await send_email(
recipients=[email],
subject="Password Reset - LeagueLedger",
body="", # Empty as we're using a template
template_name="password_reset.html",
template_body=template_data,
background_tasks=background_tasks,
)
background_tasks: BackgroundTasks
):
"""Send password reset email with a reset link"""
try:
# Create reset URL with token
reset_url = f"{APP_BASE_URL}/auth/reset-password?token={reset_token}"
# Get the email template
template = env.get_template("email/password_reset.html")
# Render the HTML content with variables
html_content = template.render(
username=username,
reset_url=reset_url,
token=reset_token
)
# Send email
subject = "Password Reset Request - LeagueLedger"
await send_email(
email_to=email_to,
subject=subject,
html_content=html_content,
background_tasks=background_tasks
)
logger.info(f"Password reset email sent to {email_to}")
return True
except Exception as e:
logger.error(f"Failed to send password reset email: {str(e)}")
return False
async def send_team_join_request_notification(
captain_email: str,
captain_name: str,
requester_name: str,
team_name: str,
message: str,
approval_token: str,
background_tasks: BackgroundTasks
):
"""Send email notification to team captain about join request"""
try:
# Create approval/denial URLs
approve_url = f"{APP_BASE_URL}/teams/approve-request/{approval_token}"
deny_url = f"{APP_BASE_URL}/teams/deny-request/{approval_token}"
# Get the email template
template = env.get_template("email/team_join_request.html")
# Render the HTML content with variables
html_content = template.render(
captain_name=captain_name,
requester_name=requester_name,
team_name=team_name,
message=message,
approve_url=approve_url,
deny_url=deny_url
)
# Send email
subject = f"Team Join Request - {requester_name} wants to join {team_name}"
await send_email(
email_to=captain_email,
subject=subject,
html_content=html_content,
background_tasks=background_tasks
)
logger.info(f"Team join request notification sent to {captain_email}")
return True
except Exception as e:
logger.error(f"Failed to send team join request notification: {str(e)}")
return False
async def send_verification_email(
email: EmailStr,
username: str,
verification_token: str,
background_tasks: Optional[BackgroundTasks] = None,
) -> None:
"""
Send email verification link
Args:
email: Recipient email address
username: User's username
verification_token: Email verification token
background_tasks: Optional BackgroundTasks for sending in background
"""
# Base URL for the application
base_url = os.getenv("LEAGUELEDGER_BASE_URL", "http://localhost:8000")
verification_link = f"{base_url}/auth/verify-email?token={verification_token}"
# Template data
template_data = {
"username": username,
"verification_link": verification_link,
"base_url": base_url,
}
# Send email
await send_email(
recipients=[email],
subject="Verify Your Email - LeagueLedger",
body="", # Empty as we're using a template
template_name="email_verification.html",
template_body=template_data,
background_tasks=background_tasks,
)
email_to=None,
username: str = None,
verification_token: str = None,
background_tasks: BackgroundTasks = None,
email=None, # Added for backward compatibility
):
"""Send email verification link to newly registered users"""
try:
# Use email parameter if email_to is not provided
recipient_email = email_to if email_to is not None else email
if not recipient_email:
logger.error("No email address provided for verification email")
return False
# Create verification URL with token
verification_link = f"{APP_BASE_URL}/auth/verify-email?token={verification_token}"
# Get the email template
template = env.get_template("email/email_verification.html")
# Render the HTML content with variables
html_content = template.render(
username=username,
verification_link=verification_link
)
# Send email
subject = "Verify Your Email Address - LeagueLedger"
await send_email(
email_to=recipient_email,
subject=subject,
html_content=html_content,
background_tasks=background_tasks
)
logger.info(f"Verification email sent to {recipient_email}")
return True
except Exception as e:
logger.error(f"Failed to send verification email: {str(e)}")
return False
async def send_welcome_email(
email: EmailStr,
async def send_join_request_response(
user_email: str,
username: str,
background_tasks: Optional[BackgroundTasks] = None,
) -> None:
"""
Send welcome email to new users
Args:
email: Recipient email address
username: User's username
background_tasks: Optional BackgroundTasks for sending in background
"""
# Get base URL from environment variables
base_url = os.getenv("LEAGUELEDGER_BASE_URL", "http://localhost:8000")
# Template data
template_data = {
"username": username,
"base_url": base_url,
}
# Send email
await send_email(
recipients=[email],
subject="Welcome to LeagueLedger!",
body="", # Empty as we're using a template
template_name="welcome.html",
template_body=template_data,
background_tasks=background_tasks,
)
team_name: str,
is_approved: bool,
background_tasks: BackgroundTasks
):
"""Send email notification about join request approval/denial"""
try:
# Get the email template
template = env.get_template("email/join_request_response.html")
# Render the HTML content with variables
html_content = template.render(
username=username,
team_name=team_name,
is_approved=is_approved,
base_url=APP_BASE_URL
)
# Send email
status = "Approved" if is_approved else "Denied"
subject = f"Team Join Request {status} - {team_name}"
await send_email(
email_to=user_email,
subject=subject,
html_content=html_content,
background_tasks=background_tasks
)
logger.info(f"Join request response email sent to {user_email}")
return True
except Exception as e:
logger.error(f"Failed to send join request response email: {str(e)}")
return False
+8 -1
View File
@@ -11,7 +11,10 @@ from typing import Dict, Any, List, Type, Optional
import inspect as py_inspect
from ..db import SessionLocal, Base
from ..models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event
from ..models import (
User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event,
OAuthAccount, TeamJoinRequest, EventAttendee, UserPoints
)
from ..templates_config import templates
router = APIRouter()
@@ -25,6 +28,10 @@ MODELS = {
'qr_set': (QRSet, "QR Sets"),
'team_achievement': (TeamAchievement, "Team Achievements"),
'event': (Event, "Events"),
'oauth_account': (OAuthAccount, "OAuth Accounts"),
'team_join_request': (TeamJoinRequest, "Team Join Requests"),
'event_attendee': (EventAttendee, "Event Attendees"),
'user_points': (UserPoints, "User Points"),
}
def get_db():
+24 -5
View File
@@ -17,15 +17,25 @@ from ..templates_config import templates
from ..security import verify_password, get_password_hash
from ..utils.mail import send_password_reset_email
router = APIRouter(prefix="/auth", tags=["Auth"])
router = APIRouter(tags=["Auth"])
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request, error: Optional[str] = None, message: Optional[str] = None):
async def login_page(request: Request, error: Optional[str] = None, message: Optional[str] = None, db: Session = Depends(get_db)):
"""Login page route"""
# Check if user is already logged in
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(User).get(user_id)
# If user is already logged in, show a message
if not message:
message = "You are already logged in."
return templates.TemplateResponse(
"auth/login.html",
{"request": request, "error": error, "message": message,
"show_oauth": True, "oauth_provider_name": "Authentik"}
"show_oauth": True, "oauth_provider_name": "Authentik",
"user": user} # Add user to context
)
@router.post("/login", response_class=HTMLResponse)
@@ -100,11 +110,20 @@ async def login_post(
return RedirectResponse(next_page, status_code=HTTP_303_SEE_OTHER)
@router.get("/register", response_class=HTMLResponse)
async def register_page(request: Request, error: Optional[str] = None):
async def register_page(request: Request, error: Optional[str] = None, db: Session = Depends(get_db)):
"""Registration page route"""
# Check if user is already logged in
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(User).get(user_id)
# If no specific error is set, inform user they're already registered
if not error:
error = "You are already registered and logged in. You can logout first if you want to create a new account."
return templates.TemplateResponse(
"auth/register.html",
{"request": request, "error": error}
{"request": request, "error": error, "user": user} # Add user to context
)
@router.post("/register", response_class=HTMLResponse)
+32
View File
@@ -0,0 +1,32 @@
"""
Router for convenience redirects to simplify common URL patterns.
"""
from fastapi import APIRouter
from fastapi.responses import RedirectResponse
router = APIRouter(tags=["Convenience"])
@router.get("/scan")
async def scan_redirect():
"""Redirect /scan to /dashboard/scan"""
return RedirectResponse("/dashboard/scan", status_code=303)
@router.get("/login")
async def login_redirect():
"""Redirect /login to /auth/login"""
return RedirectResponse("/auth/login", status_code=303)
@router.get("/register")
async def register_redirect():
"""Redirect /register to /auth/register"""
return RedirectResponse("/auth/register", status_code=303)
@router.get("/profile")
async def profile_redirect():
"""Redirect /profile to /auth/profile"""
return RedirectResponse("/auth/profile", status_code=303)
@router.get("/logout")
async def logout_redirect():
"""Redirect /logout to /auth/logout"""
return RedirectResponse("/auth/logout", status_code=303)
+262 -42
View File
@@ -2,17 +2,22 @@
"""
Teams management for users and admins.
"""
from fastapi import APIRouter, Depends, Request, Form, HTTPException
from fastapi import APIRouter, Depends, Request, Form, HTTPException, status, BackgroundTasks
from fastapi.responses import HTMLResponse, RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, desc, inspect
from sqlalchemy import func, desc, inspect, or_, and_
from datetime import datetime, timedelta
import random # For demo data
import secrets
from starlette.status import HTTP_303_SEE_OTHER
from starlette.middleware.sessions import SessionMiddleware
from ..db import SessionLocal
from ..models import Team, TeamMembership, User, QRCode, TeamAchievement, TeamMember
from ..db import SessionLocal, get_db
from ..models import Team, TeamMembership, User, QRCode, TeamAchievement, TeamMember, TeamJoinRequest
from ..schemas import TeamCreate
from ..templates_config import templates
from ..utils.auth import get_current_user, is_team_captain
from ..utils.mail import send_team_join_request_notification, send_join_request_response
router = APIRouter()
@@ -60,60 +65,260 @@ def list_teams(request: Request, db: Session = Depends(get_db)):
}
)
@router.post("/create")
def create_team(request: Request, name: str = Form(...), db: Session = Depends(get_db)):
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login?next=/teams", status_code=303)
# Get the user
user = db.query(User).get(user_id)
if not user:
return RedirectResponse("/auth/login", status_code=303)
@router.post("/create", response_class=HTMLResponse)
async def create_team_post(
request: Request,
name: str = Form(...),
description: str = Form(""),
logo_url: str = Form(""),
is_open: bool = Form(False), # Added is_open field
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Handle team creation form submission"""
if not current_user:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Check if team name already exists
existing_team = db.query(Team).filter(Team.name == name).first()
if existing_team:
# Return to teams page with error message
# In a real app, you'd add error handling/flash messages
return RedirectResponse("/teams/?error=Team+name+already+exists", status_code=303)
return RedirectResponse("/teams/?error=Team+name+already+exists", status_code=HTTP_303_SEE_OTHER)
# Create team with the user as owner
new_team = Team(name=name, owner_id=user_id)
db.add(new_team)
# Create team
team = Team(
name=name,
description=description,
logo_url=logo_url,
is_open=is_open # Save is_open value
)
db.add(team)
db.commit()
db.refresh(new_team)
db.refresh(team)
# Make the user an admin of the team in TeamMembership
team_membership = TeamMembership(
user_id=user_id,
team_id=new_team.id,
user_id=current_user.id,
team_id=team.id,
is_admin=True # User becomes admin of the team
)
db.add(team_membership)
# Check if TeamMember model exists in the database
try:
# Use a safer approach to check if the model exists and is usable
if 'team_members' in inspect(db.bind).get_table_names():
# Create TeamMember relationship as well
team_member = TeamMember(
user_id=user_id,
team_id=new_team.id,
is_captain=True # User becomes captain in TeamMember model
)
db.add(team_member)
except Exception as e:
print(f"Could not create TeamMember record: {str(e)}")
# Continue even if this fails - TeamMembership is primary relationship
# Create TeamMember relationship as well
team_member = TeamMember(
user_id=current_user.id,
team_id=team.id,
is_captain=True # Use is_captain instead of role
)
db.add(team_member)
db.commit()
return RedirectResponse("/teams/", status_code=303)
return RedirectResponse("/teams/", status_code=HTTP_303_SEE_OTHER)
@router.post("/teams/{team_id}/edit", response_class=HTMLResponse)
async def edit_team_post(
request: Request,
team_id: int,
name: str = Form(...),
description: str = Form(""),
logo_url: str = Form(""),
is_open: bool = Form(False), # Added is_open field
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Handle team edit form submission"""
if not current_user:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
team = db.query(Team).filter(Team.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(
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 = name
team.description = description
team.logo_url = logo_url
team.is_open = is_open # Update is_open value
db.commit()
return RedirectResponse(f"/teams/{team_id}", status_code=HTTP_303_SEE_OTHER)
@router.get("/{team_id}/join", response_class=HTMLResponse)
async def join_team_page(
request: Request,
team_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Page for joining a team"""
if not current_user:
return RedirectResponse(f"/auth/login?next=/teams/{team_id}/join", status_code=HTTP_303_SEE_OTHER)
# Check if team exists
team = db.query(Team).filter(Team.id == team_id, Team.is_active == True).first()
if not team:
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "Team not found"}
)
# Check if user is already a member
existing_membership = db.query(TeamMember).filter(
TeamMember.team_id == team_id,
TeamMember.user_id == current_user.id
).first()
if existing_membership:
return templates.TemplateResponse(
"teams/join_team.html",
{
"request": request,
"team": team,
"error": "You are already a member of this team"
}
)
# Check if there's a pending join request
pending_request = db.query(TeamJoinRequest).filter(
TeamJoinRequest.team_id == team_id,
TeamJoinRequest.user_id == current_user.id,
TeamJoinRequest.status == "pending"
).first()
if pending_request:
return templates.TemplateResponse(
"teams/join_team.html",
{
"request": request,
"team": team,
"error": "You already have a pending join request for this team"
}
)
return templates.TemplateResponse(
"teams/join_team.html",
{"request": request, "team": team, "is_open": team.is_open}
)
@router.post("/{team_id}/join", response_class=HTMLResponse)
async def join_team_request(
request: Request,
team_id: int,
background_tasks: BackgroundTasks,
message: str = Form(""),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Process join team request"""
if not current_user:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Check if team exists
team = db.query(Team).filter(Team.id == team_id, Team.is_active == True).first()
if not team:
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "Team not found"}
)
# Check if user is already a member
existing_membership = db.query(TeamMember).filter(
TeamMember.team_id == team_id,
TeamMember.user_id == current_user.id
).first()
if existing_membership:
return RedirectResponse(f"/teams/{team_id}", status_code=HTTP_303_SEE_OTHER)
# Open team - directly add the user
if team.is_open:
# Add user to team
new_member = TeamMember(
team_id=team_id,
user_id=current_user.id,
is_captain=False # Use is_captain instead of role
)
db.add(new_member)
db.commit()
return RedirectResponse(
f"/teams/{team_id}?message=You+have+joined+the+team+successfully",
status_code=HTTP_303_SEE_OTHER
)
# Closed team - create join request
# Check for existing pending request
existing_request = db.query(TeamJoinRequest).filter(
TeamJoinRequest.team_id == team_id,
TeamJoinRequest.user_id == current_user.id,
TeamJoinRequest.status == "pending"
).first()
if existing_request:
return RedirectResponse(
f"/teams/{team_id}?message=Your+join+request+is+pending+approval",
status_code=HTTP_303_SEE_OTHER
)
# Create request token
request_token = secrets.token_urlsafe(32)
# Create join request
join_request = TeamJoinRequest(
team_id=team_id,
user_id=current_user.id,
message=message,
request_token=request_token
)
db.add(join_request)
db.commit()
# Get team captains to notify
captains = db.query(User).join(TeamMember).filter(
TeamMember.team_id == team_id,
TeamMember.is_captain == True # Use is_captain instead of role
).all()
if not captains:
print("No captains found for the team. Unable to send notifications.")
else:
# Send email notifications to all captains
for captain in captains:
try:
await send_team_join_request_notification(
captain_email=captain.email,
captain_name=captain.username,
requester_name=current_user.username,
team_name=team.name,
message=message,
approval_token=request_token,
background_tasks=background_tasks
)
# Log success
print(f"Team join request notification sent to {captain.email}")
except Exception as e:
# Log the error but continue
print(f"Failed to send notification to {captain.email}: {str(e)}")
return RedirectResponse(
f"/teams/{team_id}?message=Your+join+request+has+been+submitted+for+approval",
status_code=HTTP_303_SEE_OTHER
)
@router.post("/join/{team_id}")
def join_team(request: Request, team_id: int, db: Session = Depends(get_db)):
def direct_join_team(request: Request, team_id: int, db: Session = Depends(get_db)):
"""Handle direct team join requests"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
@@ -136,14 +341,27 @@ def join_team(request: Request, team_id: int, db: Session = Depends(get_db)):
if existing:
return RedirectResponse("/teams/?error=You+are+already+a+member+of+this+team", status_code=303)
# Check if team is closed (not open)
if not team.is_open:
# For closed teams, redirect to the join request page
return RedirectResponse(f"/teams/{team_id}/join", status_code=303)
# Create membership
# Create membership for open teams
new_member = TeamMembership(
user_id=user_id,
team_id=team.id,
is_admin=False
)
db.add(new_member)
# Also create TeamMember record for consistency
team_member = TeamMember(
user_id=user_id,
team_id=team.id,
is_captain=False # Use is_captain instead of role
)
db.add(team_member)
db.commit()
# Redirect to the team detail page
@@ -347,6 +565,7 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
# Safely get team attributes
is_public = getattr(team, 'is_public', False)
is_open = getattr(team, 'is_open', False) # Add this line to get is_open status
created_at = getattr(team, 'created_at', None)
# Calculate days since team was founded
@@ -382,6 +601,7 @@ def team_detail(request: Request, team_id: int, db: Session = Depends(get_db)):
"is_team_member": is_team_member,
"days_ago": days_ago,
"founded_date": founded_date_str,
"user": user # Add user to the context
"user": user, # Add user to the context
"is_open": is_open # Pass is_open to the template
}
)
+6
View File
@@ -0,0 +1,6 @@
"""
Teams module for LeagueLedger - handles team management functionality.
"""
from .routes import router
__all__ = ['router']
+495
View File
@@ -0,0 +1,495 @@
"""Team-related actions for team management"""
from fastapi import Request, Depends, Form, HTTPException, BackgroundTasks
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
import secrets
from starlette.status import HTTP_303_SEE_OTHER
from fastapi.templating import Jinja2Templates
from ...models import Team, TeamMembership, User, TeamJoinRequest
from ...utils.auth import get_current_user
from ...utils.mail import send_team_join_request_notification, send_join_request_response
from ...templates_config import templates
from .routes import get_db
from . import utils
async def create_team_post(
request: Request,
name: str = Form(...),
description: str = Form(""),
logo_url: str = Form(""),
is_open: bool = Form(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Handle team creation form submission"""
if not current_user:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Check if team name already exists
existing_team = db.query(Team).filter(Team.name == name).first()
if existing_team:
return RedirectResponse("/teams/?error=Team+name+already+exists", status_code=HTTP_303_SEE_OTHER)
# Create team with only the fields that exist in the model
team_data = {
"name": name,
"description": description,
"is_open": is_open,
"owner_id": current_user.id
}
# Only add logo_url if it exists in the Team model
from sqlalchemy import inspect
team_columns = [c.key for c in inspect(Team).columns]
if "logo_url" in team_columns:
team_data["logo_url"] = logo_url
team = Team(**team_data)
db.add(team)
db.commit()
db.refresh(team)
# Make the user an admin and captain of the team
team_membership = TeamMembership(
user_id=current_user.id,
team_id=team.id,
is_admin=True,
is_captain=True
)
db.add(team_membership)
db.commit()
return RedirectResponse("/teams/", status_code=HTTP_303_SEE_OTHER)
async def edit_team_post(
request: Request,
team_id: int,
name: str = Form(...),
description: str = Form(""),
logo_url: str = Form(""),
is_open: bool = Form(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Handle team edit form submission"""
if not current_user:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
team = db.query(Team).filter(Team.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(
team_id=team.id,
user_id=current_user.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 = name
team.description = description
team.logo_url = logo_url
team.is_open = is_open
db.commit()
return RedirectResponse(f"/teams/{team_id}", status_code=HTTP_303_SEE_OTHER)
async def join_team_request(
request: Request,
team_id: int,
background_tasks: BackgroundTasks,
message: str = Form(""),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Process join team request"""
if not current_user:
return RedirectResponse("/auth/login", status_code=HTTP_303_SEE_OTHER)
# Check if team exists
team = db.query(Team).filter(Team.id == team_id, Team.is_active == True).first()
if not team:
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "Team not found"}
)
# Check if user is already a member
existing_membership = db.query(TeamMembership).filter(
TeamMembership.team_id == team_id,
TeamMembership.user_id == current_user.id
).first()
if existing_membership:
return RedirectResponse(f"/teams/{team_id}", status_code=HTTP_303_SEE_OTHER)
# Open team - directly add the user
if team.is_open:
# Add user to team
new_member = TeamMembership(
team_id=team_id,
user_id=current_user.id,
is_admin=False,
is_captain=False
)
db.add(new_member)
db.commit()
return RedirectResponse(
f"/teams/{team_id}?message=You+have+joined+the+team+successfully",
status_code=HTTP_303_SEE_OTHER
)
# Closed team - create join request
# Check for existing pending request
existing_request = db.query(TeamJoinRequest).filter(
TeamJoinRequest.team_id == team_id,
TeamJoinRequest.user_id == current_user.id,
TeamJoinRequest.status == "pending"
).first()
if existing_request:
return RedirectResponse(
f"/teams/{team_id}?message=Your+join+request+is+pending+approval",
status_code=HTTP_303_SEE_OTHER
)
# Create request token
request_token = secrets.token_urlsafe(32)
# Create join request
join_request = TeamJoinRequest(
team_id=team_id,
user_id=current_user.id,
message=message,
request_token=request_token
)
db.add(join_request)
db.commit()
# Get team captains to notify - Updated to use TeamMembership instead of TeamMember
captains = db.query(User).join(TeamMembership).filter(
TeamMembership.team_id == team_id,
TeamMembership.is_captain == True
).all()
if not captains:
print("No captains found for the team. Unable to send notifications.")
else:
# Send email notifications to all captains
for captain in captains:
try:
await send_team_join_request_notification(
captain_email=captain.email,
captain_name=captain.username,
requester_name=current_user.username,
team_name=team.name,
message=message,
approval_token=request_token,
background_tasks=background_tasks
)
# Log success
print(f"Team join request notification sent to {captain.email}")
except Exception as e:
# Log the error but continue
print(f"Failed to send notification to {captain.email}: {str(e)}")
return RedirectResponse(
f"/teams/{team_id}?message=Your+join+request+has+been+submitted+for+approval",
status_code=HTTP_303_SEE_OTHER
)
def direct_join_team(request: Request, team_id: int, db: Session = Depends(get_db)):
"""Handle direct team join requests"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login?next=/teams", status_code=303)
# Get user
user = db.query(User).get(user_id)
if not user:
return RedirectResponse("/auth/login", status_code=303)
# Find the team
team = db.query(Team).filter_by(id=team_id).first()
if not team:
return RedirectResponse("/teams/?error=Team+not+found", 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/?error=You+are+already+a+member+of+this+team", status_code=303)
# Check if team is closed (not open)
if not team.is_open:
# For closed teams, redirect to the join request page
return RedirectResponse(f"/teams/{team_id}/join", status_code=303)
# Create membership for open teams
new_member = TeamMembership(
user_id=user_id,
team_id=team.id,
is_admin=False,
is_captain=False
)
db.add(new_member)
db.commit()
# Redirect to the team detail page
return RedirectResponse(f"/teams/{team_id}", status_code=303)
def leave_team(request: Request, team_id: int, db: Session = Depends(get_db)):
"""Allow a user to leave a team"""
# Check if user is logged in
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login?next=/teams", status_code=303)
# Get team
team = db.query(Team).filter_by(id=team_id).first()
if not team:
raise HTTPException(status_code=404, detail="Team not found")
# Can't leave if you're the owner
if team.owner_id == user_id:
return RedirectResponse(f"/teams/{team_id}?error=Team+owner+cannot+leave", status_code=303)
# Find membership
membership = db.query(TeamMembership)\
.filter(TeamMembership.user_id == user_id, TeamMembership.team_id == team_id)\
.first()
if not membership:
return RedirectResponse("/teams/?error=You+are+not+a+member+of+this+team", status_code=303)
# Delete the team membership
db.delete(membership)
db.commit()
return RedirectResponse("/teams/?message=Successfully+left+the+team", status_code=303)
def update_team(
request: Request,
team_id: int,
team_name: str = Form(...),
is_public: bool = Form(False),
db: Session = Depends(get_db)
):
"""Update team details."""
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(
team_id=team.id,
user_id=request.session.get("user_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)
async def approve_join_request(request: Request, token: str, db: Session = Depends(get_db)):
"""Approve a team join request using the provided token"""
# Find the join request by token
join_request = db.query(TeamJoinRequest).filter_by(request_token=token).first()
if not join_request:
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "Invalid or expired join request"}
)
if join_request.status != "pending":
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "This request has already been processed"}
)
# Get the team
team = db.query(Team).filter_by(id=join_request.team_id).first()
if not team:
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "Team not found"}
)
# Check if the user has permission to approve requests
# Get user from session
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login", status_code=303)
user = db.query(User).get(user_id)
if not user:
return RedirectResponse("/auth/login", status_code=303)
# Check if user is admin, owner or captain
is_user_admin, is_user_owner, is_captain = utils.check_user_permissions(db, user, join_request.team_id, team)
if not (is_user_admin or is_user_owner or is_captain):
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "You don't have permission to approve join requests"}
)
# Get the user who requested to join
requester = db.query(User).filter_by(id=join_request.user_id).first()
if not requester:
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "Requesting user not found"}
)
# Check if user is already a member
existing_membership = db.query(TeamMembership).filter(
TeamMembership.team_id == join_request.team_id,
TeamMembership.user_id == join_request.user_id
).first()
if existing_membership:
# Update request status
join_request.status = "approved"
db.commit()
return templates.TemplateResponse(
"teams/request_processed.html",
{
"request": request,
"message": f"{requester.username} is already a member of {team.name}",
"team_id": team.id
}
)
# Create team membership
new_member = TeamMembership(
team_id=join_request.team_id,
user_id=join_request.user_id,
is_admin=False,
is_captain=False
)
db.add(new_member)
# Update request status
join_request.status = "approved"
db.commit()
# Send notification to the user (if email sending is available)
try:
background_tasks = BackgroundTasks()
await send_join_request_response(
user_email=requester.email,
user_name=requester.username,
team_name=team.name,
approved=True,
background_tasks=background_tasks
)
except Exception as e:
print(f"Failed to send approval notification: {str(e)}")
return templates.TemplateResponse(
"teams/request_processed.html",
{
"request": request,
"message": f"Successfully approved {requester.username}'s request to join {team.name}",
"team_id": team.id
}
)
async def deny_join_request(request: Request, token: str, db: Session = Depends(get_db)):
"""Deny a team join request using the provided token"""
# Find the join request by token
join_request = db.query(TeamJoinRequest).filter_by(request_token=token).first()
if not join_request:
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "Invalid or expired join request"}
)
if join_request.status != "pending":
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "This request has already been processed"}
)
# Get the team
team = db.query(Team).filter_by(id=join_request.team_id).first()
if not team:
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "Team not found"}
)
# Check if the user has permission to deny requests
# Get user from session
user_id = request.session.get("user_id")
if not user_id:
return RedirectResponse("/auth/login", status_code=303)
user = db.query(User).get(user_id)
if not user:
return RedirectResponse("/auth/login", status_code=303)
# Check if user is admin, owner or captain
is_user_admin, is_user_owner, is_captain = utils.check_user_permissions(db, user, join_request.team_id, team)
if not (is_user_admin or is_user_owner or is_captain):
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "You don't have permission to deny join requests"}
)
# Get the user who requested to join
requester = db.query(User).filter_by(id=join_request.user_id).first()
if not requester:
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "Requesting user not found"}
)
# Update request status
join_request.status = "denied"
db.commit()
# Send notification to the user (if email sending is available)
try:
background_tasks = BackgroundTasks()
await send_join_request_response(
user_email=requester.email,
user_name=requester.username,
team_name=team.name,
approved=False,
background_tasks=background_tasks
)
except Exception as e:
print(f"Failed to send denial notification: {str(e)}")
return templates.TemplateResponse(
"teams/request_processed.html",
{
"request": request,
"message": f"Successfully denied {requester.username}'s request to join {team.name}",
"team_id": team.id
}
)
+57
View File
@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from ...db import SessionLocal, get_db
from . import views, actions
from ...auth.utils import get_current_user_from_session
router = APIRouter()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Main routes for teams
@router.get("/", response_class=HTMLResponse)
def list_teams(request: Request, db: Session = Depends(get_db)):
"""List all available teams"""
# No need to explicitly get current user - it's in request.state.user
# from middleware and will be passed to the template
return views.list_teams_view(request, db)
@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."""
# User is already available in request.state.user
return views.team_detail_view(request, team_id, db)
@router.get("/{team_id}/join", response_class=HTMLResponse)
async def join_team_page(request: Request, team_id: int, db: Session = Depends(get_db)):
"""Page for joining a team"""
# User is already available in request.state.user
return await views.join_team_page_view(request, team_id, db)
# Action routes
router.add_api_route("/create", actions.create_team_post, methods=["POST"], response_class=HTMLResponse)
router.add_api_route("/{team_id}/edit", actions.edit_team_post, methods=["POST"], response_class=HTMLResponse)
router.add_api_route("/{team_id}/join", actions.join_team_request, methods=["POST"], response_class=HTMLResponse)
router.add_api_route("/join/{team_id}", actions.direct_join_team, methods=["POST"])
router.add_api_route("/{team_id}/leave", actions.leave_team, methods=["POST"])
router.add_api_route("/{team_id}/update", actions.update_team, methods=["POST"])
# Add these new routes for handling join requests
@router.get("/approve-request/{token}", response_class=HTMLResponse)
async def approve_join_request(request: Request, token: str, db: Session = Depends(get_db)):
"""Approve a team join request using the provided token"""
# User is already available in request.state.user
return await actions.approve_join_request(request, token, db)
@router.get("/deny-request/{token}", response_class=HTMLResponse)
async def deny_join_request(request: Request, token: str, db: Session = Depends(get_db)):
"""Deny a team join request using the provided token"""
# User is already available in request.state.user
return await actions.deny_join_request(request, token, db)
+166
View File
@@ -0,0 +1,166 @@
"""Helper functions for team views and actions"""
from sqlalchemy.orm import Session
from sqlalchemy import func, inspect
from datetime import datetime, timedelta
import random
from ...models import Team, TeamMembership, User, QRCode
def get_team_members_with_details(db: Session, team_id: int):
"""Get team members with additional details"""
memberships = db.query(TeamMembership).filter_by(team_id=team_id).all()
team_members = []
for membership in memberships:
member = db.query(User).filter_by(id=membership.user_id).first()
if member:
# Use joined_at if available, otherwise use placeholder
joined_date = membership.joined_at 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,
"is_captain": membership.is_captain,
"joined": f"{month_name} {year}"
})
return team_members
def check_user_permissions(db: Session, user, team_id: int, team):
"""Check if user is admin or owner of the team"""
is_user_admin = False
is_user_owner = False
is_captain = False
if user:
# Check admin status
membership = db.query(TeamMembership).filter_by(
team_id=team_id,
user_id=user.id,
is_admin=True
).first()
is_user_admin = membership is not None
# Check captain status
captain_membership = db.query(TeamMembership).filter_by(
team_id=team_id,
user_id=user.id,
is_captain=True
).first()
is_captain = captain_membership is not None
# Check owner status
is_user_owner = hasattr(team, 'owner_id') and team.owner_id == user.id
if is_user_owner:
is_user_admin = True # Owner has admin privileges
return is_user_admin, is_user_owner, is_captain
def get_team_total_points(db: Session, team_id: int):
"""Get total points for a team"""
total_points = db.query(func.sum(QRCode.points)).filter(
QRCode.redeemed_at_team == team_id
).scalar() or 0
return total_points
def calculate_team_rank(db: Session, team_id: int):
"""Calculate team rank based on points"""
try:
# First, get the aggregated points for all teams
team_points = db.query(
QRCode.redeemed_at_team,
func.sum(QRCode.points).label('total')
).filter(
QRCode.redeemed_at_team != None
).group_by(QRCode.redeemed_at_team).all()
# Sort them by points (descending)
sorted_teams = sorted(team_points, key=lambda x: x.total or 0, reverse=True)
# Find our team's position
team_rank = 1
for idx, team_data in enumerate(sorted_teams):
if team_data.redeemed_at_team == team_id:
team_rank = idx + 1
break
return team_rank
except Exception as e:
print(f"Error calculating team rank: {e}")
return 1 # Default to 1st place on error
def get_team_points_history(db: Session, team_id: int):
"""Get points history for a team"""
# Default values
points_this_month = 65
point_change = 15
point_change_positive = True
# Try to get actual data if available
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_codes')]:
has_redeemed_at = True
if has_redeemed_at:
points_this_month = db.query(func.sum(QRCode.points)).filter(
QRCode.redeemed_at_team == team_id,
QRCode.redeemed_at >= first_day_of_month
).scalar() or points_this_month
except Exception as e:
print(f"Error calculating monthly points: {e}")
return points_this_month, point_change, point_change_positive
def get_team_activities():
"""Get team activities (currently returns mock data)"""
return [
{
"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"
}
]
def get_team_age(team):
"""Calculate team age"""
created_at = getattr(team, 'created_at', None)
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
return days_ago, founded_date_str
+178
View File
@@ -0,0 +1,178 @@
"""Team-related view functions for rendering templates"""
from fastapi import Request, HTTPException
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
from datetime import datetime, timedelta
import random
from sqlalchemy import inspect
from ...models import Team, TeamMembership, User, QRCode, TeamJoinRequest
from ...templates_config import templates
from ...utils.auth import get_current_user
from . import utils
def list_teams_view(request: Request, db: Session):
"""Render the teams list view"""
teams = db.query(Team).all()
# Get the user's teams to highlight teams they're already in
user_team_ids = []
# Get user from session for navbar
user = None
user_id = request.session.get("user_id")
if user_id:
user = db.query(User).get(user_id)
# Get teams that user is a member of
memberships = db.query(TeamMembership).filter(TeamMembership.user_id == user_id).all()
user_team_ids = [membership.team_id for membership in memberships]
# Get error message if present
error = request.query_params.get("error")
return templates.TemplateResponse(
"teams.html",
{
"request": request,
"teams": teams,
"user_team_ids": user_team_ids,
"user": user,
"error": error,
"brand_colors": {
"irish_green": "#006837",
"golden_ale": "#FFB400",
"cream_white": "#F5F0E1",
"black_stout": "#1A1A1A",
"guinness_red": "#B22222"
}
}
)
async def join_team_page_view(request: Request, team_id: int, db: Session):
"""Render the join team page"""
user_id = request.session.get("user_id")
current_user = db.query(User).get(user_id) if user_id else None
if not current_user:
return RedirectResponse(f"/auth/login?next=/teams/{team_id}/join", status_code=303)
# Check if team exists
team = db.query(Team).filter(Team.id == team_id, Team.is_active == True).first()
if not team:
return templates.TemplateResponse(
"error.html",
{"request": request, "error": "Team not found"}
)
# Check if user is already a member
existing_membership = db.query(TeamMembership).filter(
TeamMembership.team_id == team_id,
TeamMembership.user_id == current_user.id
).first()
if existing_membership:
return templates.TemplateResponse(
"teams/join_team.html",
{
"request": request,
"team": team,
"error": "You are already a member of this team"
}
)
# Check if there's a pending join request
pending_request = db.query(TeamJoinRequest).filter(
TeamJoinRequest.team_id == team_id,
TeamJoinRequest.user_id == current_user.id,
TeamJoinRequest.status == "pending"
).first()
if pending_request:
return templates.TemplateResponse(
"teams/join_team.html",
{
"request": request,
"team": team,
"error": "You already have a pending join request for this team"
}
)
return templates.TemplateResponse(
"teams/join_team.html",
{"request": request, "team": team, "is_open": team.is_open}
)
def team_detail_view(request: Request, team_id: int, db: Session):
"""Render the team detail page"""
# 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 user from session for navbar
user = None
user_id = request.session.get("user_id")
is_team_member = False
if user_id:
user = db.query(User).get(user_id)
# Check if user is a team member
team_membership = db.query(TeamMembership)\
.filter(TeamMembership.user_id == user_id, TeamMembership.team_id == team_id)\
.first()
is_team_member = team_membership is not None
# Get team members with user info
team_members = utils.get_team_members_with_details(db, team_id)
# Check if user is admin or owner
is_user_admin, is_user_owner, is_captain = utils.check_user_permissions(db, user, team_id, team)
# Get team statistics
total_points = utils.get_team_total_points(db, team_id)
team_rank = utils.calculate_team_rank(db, team_id)
points_this_month, point_change, point_change_positive = utils.get_team_points_history(db, team_id)
# Activities - simple mock data for now
activities = utils.get_team_activities()
# Get team metadata
days_ago, founded_date_str = utils.get_team_age(team)
# Performance metrics
performance = {
"last_quiz": "25 points (2nd place)",
"average": "18.7 points",
"best_streak": "3 wins in a row"
}
# Safely get team attributes
is_public = getattr(team, 'is_public', False)
is_open = getattr(team, 'is_open', False)
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,
"is_user_owner": is_user_owner,
"is_team_member": is_team_member,
"is_captain": is_captain,
"days_ago": days_ago,
"founded_date": founded_date_str,
"user": user,
"is_open": is_open
}
)