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:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -1 +1,4 @@
|
||||
"""
|
||||
Utility functions for LeagueLedger.
|
||||
"""
|
||||
# Utils package initialization
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Teams module for LeagueLedger - handles team management functionality.
|
||||
"""
|
||||
from .routes import router
|
||||
|
||||
__all__ = ['router']
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user