From 7323c12168a4b1893f98d1fedbbeb2d8ecc76268 Mon Sep 17 00:00:00 2001 From: Christian Krakau-Louis Date: Mon, 14 Apr 2025 17:00:57 +0200 Subject: [PATCH] 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. --- app/auth/utils.py | 26 + app/config.py | 55 ++ app/main.py | 104 ++-- app/models.py | 52 +- .../email/join_request_response.html | 74 +++ app/templates/email/password_reset.html | 18 +- app/templates/email/team_join_request.html | 86 +++ app/templates/error.html | 35 +- app/templates/team_detail.html | 22 +- app/templates/teams/create_team.html | 17 + app/templates/teams/edit_team.html | 18 + app/templates/teams/join_requests.html | 90 ++++ app/templates/teams/join_team.html | 56 ++ app/templates/teams/request_processed.html | 26 + app/utils/__init__.py | 3 + app/utils/auth.py | 132 +++++ app/utils/mail.py | 367 +++++++------ app/views/admin.py | 9 +- app/views/auth.py | 29 +- app/views/convenience.py | 32 ++ app/views/teams.py | 304 +++++++++-- app/views/teams/__init__.py | 6 + app/views/teams/actions.py | 495 ++++++++++++++++++ app/views/teams/routes.py | 57 ++ app/views/teams/utils.py | 166 ++++++ app/views/teams/views.py | 178 +++++++ 26 files changed, 2138 insertions(+), 319 deletions(-) create mode 100644 app/auth/utils.py create mode 100644 app/config.py create mode 100644 app/templates/email/join_request_response.html create mode 100644 app/templates/email/team_join_request.html create mode 100644 app/templates/teams/create_team.html create mode 100644 app/templates/teams/edit_team.html create mode 100644 app/templates/teams/join_requests.html create mode 100644 app/templates/teams/join_team.html create mode 100644 app/templates/teams/request_processed.html create mode 100644 app/utils/auth.py create mode 100644 app/views/convenience.py create mode 100644 app/views/teams/__init__.py create mode 100644 app/views/teams/actions.py create mode 100644 app/views/teams/routes.py create mode 100644 app/views/teams/utils.py create mode 100644 app/views/teams/views.py diff --git a/app/auth/utils.py b/app/auth/utils.py new file mode 100644 index 0000000..f5f2bf7 --- /dev/null +++ b/app/auth/utils.py @@ -0,0 +1,26 @@ +from fastapi import Request +from sqlalchemy.orm import Session +from ..models import User +import logging + +logger = logging.getLogger(__name__) + +def get_current_user_from_session(request: Request, db: Session): + """Get the current user from the session.""" + try: + if hasattr(request.state, "user") and request.state.user is not None: + # User is already in request state, return it + return request.state.user + + if hasattr(request, "session"): + user_id = request.session.get("user_id") + if user_id: + user = db.query(User).filter(User.id == user_id).first() + if user: + # Set it in the request state for future use + request.state.user = user + return user + except Exception as e: + logger.error(f"Error retrieving user from session: {str(e)}") + + return None diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..945573c --- /dev/null +++ b/app/config.py @@ -0,0 +1,55 @@ +import os +from datetime import timedelta +from pydantic import BaseSettings, PostgresDsn, EmailStr +import secrets + +class Settings(BaseSettings): + # Base URL + BASE_URL: str = os.getenv("LEAGUELEDGER_BASE_URL", "http://localhost:8000") + + # Database settings + DB_HOST: str = os.getenv("DB_HOST", "localhost") + DB_PORT: str = os.getenv("DB_PORT", "3306") + DB_NAME: str = os.getenv("DB_NAME", "leagueledger") + DB_USER: str = os.getenv("DB_USER", "root") + DB_PASS: str = os.getenv("DB_PASS", "") + + # MySQL connection string + DATABASE_URL: str = f"mysql+mysqldb://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}" + + # Security settings + SECRET_KEY: str = os.getenv("SECRET_KEY", secrets.token_urlsafe(32)) + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 # 1 day + + # Session settings + SESSION_MAX_AGE: int = 86400 # 1 day in seconds + + # Email settings + MAIL_USERNAME: str = os.getenv("MAIL_USERNAME", "") + MAIL_PASSWORD: str = os.getenv("MAIL_PASSWORD", "") + MAIL_FROM: str = os.getenv("MAIL_FROM", "noreply@leagueledger.net") + MAIL_FROM_NAME: str = os.getenv("MAIL_FROM_NAME", "LeagueLedger") + MAIL_PORT: int = int(os.getenv("MAIL_PORT", "587")) + MAIL_SERVER: str = os.getenv("MAIL_SERVER", "localhost") + MAIL_STARTTLS: bool = os.getenv("MAIL_STARTTLS", "True").lower() == "true" + MAIL_SSL_TLS: bool = os.getenv("MAIL_SSL_TLS", "False").lower() == "true" + MAIL_USE_CREDENTIALS: bool = os.getenv("MAIL_USE_CREDENTIALS", "True").lower() == "true" + MAIL_VALIDATE_CERTS: bool = os.getenv("MAIL_VALIDATE_CERTS", "True").lower() == "true" + + # OAuth Settings + GOOGLE_CLIENT_ID: str = os.getenv("GOOGLE_CLIENT_ID", "") + GOOGLE_CLIENT_SECRET: str = os.getenv("GOOGLE_CLIENT_SECRET", "") + + # QR Code settings + QR_CODE_BOX_SIZE: int = 10 + QR_CODE_BORDER: int = 4 + + # Team settings + MAX_TEAM_SIZE: int = 10 + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + +settings = Settings() diff --git a/app/main.py b/app/main.py index 572d244..28bdecb 100644 --- a/app/main.py +++ b/app/main.py @@ -3,6 +3,7 @@ from fastapi import FastAPI, Request, Depends, Form from fastapi.staticfiles import StaticFiles from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates +from fastapi.middleware.cors import CORSMiddleware from pathlib import Path import os from starlette.middleware.sessions import SessionMiddleware @@ -13,7 +14,7 @@ import contextlib from .db import init_db, engine, get_db from . import models from .templates_config import templates -from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth +from .views import qr, redeem, teams, admin, leaderboard, dashboard, static, pages, auth, convenience from .db_init import seed_db from .db_migrations import apply_migrations @@ -27,9 +28,18 @@ load_dotenv() # Create the FastAPI application app = FastAPI(title="LeagueLedger") +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, replace with specific origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + # Add SessionMiddleware with a secure secret key -# Must be added first before other middleware to ensure it's available -app.add_middleware(SessionMiddleware, secret_key=os.getenv("SESSION_SECRET_KEY", "your-very-secret-session-key")) +SECRET_KEY = os.getenv("SECRET_KEY", "a-very-secure-secret-key-for-development") +app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY) # Initialize database on startup @app.on_event("startup") @@ -69,68 +79,58 @@ templates = Jinja2Templates(directory="app/templates") # User context middleware @app.middleware("http") async def add_template_globals(request: Request, call_next): - """Add template globals""" + """Add global variables to all templates.""" try: - # Update template globals for all templates - user = None - if hasattr(request, "session") and "user_id" in request.session and request.session.get("is_authenticated"): - # Mock user object - in a real app, you'd fetch this from the database - user = { - "id": request.session["user_id"], - "username": request.session.get("username", "User"), - "is_admin": request.session.get("is_admin", False) - } - templates.env.globals["current_user"] = user + # Set user in request state for templates + if hasattr(request, "session"): + user_id = request.session.get("user_id") + if user_id: + # Get a database session + from sqlalchemy.orm import Session + from .db import SessionLocal + from .models import User + + db = SessionLocal() + try: + # Fetch actual user from database + user = db.query(User).filter(User.id == user_id).first() + if user: + request.state.user = user + else: + request.state.user = None + finally: + db.close() + else: + request.state.user = None + else: + request.state.user = None except Exception as e: + # Log error but continue processing logger.error(f"Error setting template globals: {str(e)}") - - # Process the request + request.state.user = None + + # Continue with request response = await call_next(request) return response -@app.get("/", response_class=HTMLResponse) -async def read_root(request: Request): - user = None - if "user_id" in request.session and request.session.get("is_authenticated"): - user = { - "id": request.session["user_id"], - "username": request.session.get("username", "User"), - "is_admin": request.session.get("is_admin", False) - } +# Handle exceptions +@app.exception_handler(404) +async def not_found_exception_handler(request: Request, exc): + """Handle 404 errors with a custom template.""" return templates.TemplateResponse( - "index.html", - {"request": request, "user": user} + "error.html", + {"request": request, "error": "Page not found"}, + status_code=404 ) # Routers app.include_router(pages.router, tags=["Pages"]) # Pages router for index and static pages -app.include_router(auth.router) # Include the auth router +app.include_router(auth.router, prefix="/auth", tags=["auth"]) # Include the auth router app.include_router(qr.router, prefix="/qr", tags=["QR"]) app.include_router(redeem.router, prefix="/redeem", tags=["Redeem"]) -app.include_router(teams.router, prefix="/teams", tags=["Teams"]) +app.include_router(teams.router, prefix="/teams", tags=["teams"]) app.include_router(admin.router, prefix="/admin", tags=["Admin"]) -app.include_router(leaderboard.router, prefix="/leaderboard", tags=["Leaderboard"]) +app.include_router(leaderboard.router, prefix="/leaderboard", tags=["leaderboard"]) app.include_router(dashboard.router, prefix="/dashboard", tags=["Dashboard"]) app.include_router(static.router, tags=["Static"]) # Include the static router - -# Add a direct route for /scan that redirects to /dashboard/scan -@app.get("/scan") -async def scan_redirect(): - return RedirectResponse("/dashboard/scan", status_code=303) - -# Add convenience routes for auth paths -@app.get("/login") -async def login_redirect(): - return RedirectResponse("/auth/login", status_code=303) - -@app.get("/register") -async def register_redirect(): - return RedirectResponse("/auth/register", status_code=303) - -@app.get("/profile") -async def profile_redirect(): - return RedirectResponse("/auth/profile", status_code=303) - -@app.get("/logout") -async def logout_redirect(): - return RedirectResponse("/auth/logout", status_code=303) +app.include_router(convenience.router, tags=["Convenience"]) # Include convenience routes diff --git a/app/models.py b/app/models.py index ba5194b..7810f2c 100644 --- a/app/models.py +++ b/app/models.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 -from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, Text, Float, UniqueConstraint from sqlalchemy.orm import relationship from sqlalchemy.sql import func from sqlalchemy.ext.declarative import declarative_base +from datetime import datetime # This Base should be the single source of truth Base = declarative_base() @@ -33,7 +34,6 @@ class User(Base): # Relationships memberships = relationship("TeamMembership", back_populates="user") - teams = relationship("TeamMember", back_populates="user") points = relationship("UserPoints", back_populates="user") events_attended = relationship("EventAttendee", back_populates="user") owned_teams = relationship("Team", back_populates="owner") @@ -61,41 +61,51 @@ class Team(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String(100), unique=True, nullable=False) description = Column(Text, nullable=True) + logo_url = Column(String(255), nullable=True) # Add logo URL field is_public = Column(Boolean, default=False) # For team privacy setting - created_at = Column(DateTime, server_default=func.now()) # For team founded date - updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now()) + is_open = Column(Boolean, default=False) # Whether anyone can join without approval + is_active = Column(Boolean, default=True) # Add this column to fix the error + created_at = Column(DateTime, default=datetime.utcnow) # For team founded date + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) owner_id = Column(Integer, ForeignKey("users.id"), nullable=True) # Relationships - memberships = relationship("TeamMembership", back_populates="team") - members = relationship("TeamMember", back_populates="team") + members = relationship("TeamMembership", back_populates="team", cascade="all, delete-orphan") owner = relationship("User", back_populates="owned_teams") +class TeamJoinRequest(Base): + __tablename__ = "team_join_requests" + id = Column(Integer, primary_key=True, index=True) + team_id = Column(Integer, ForeignKey("teams.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + message = Column(String(500), nullable=True) # Specified length for VARCHAR + status = Column(String(20), default="pending") # pending, approved, denied + request_token = Column(String(100), unique=True, nullable=False, index=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + team = relationship("Team") + user = relationship("User") + + __table_args__ = (UniqueConstraint('team_id', 'user_id', 'status', name='_team_user_request_status_uc'),) + + class TeamMembership(Base): __tablename__ = "team_membership" id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id")) - team_id = Column(Integer, ForeignKey("teams.id")) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + team_id = Column(Integer, ForeignKey("teams.id"), nullable=False) is_admin = Column(Boolean, default=False) + is_captain = Column(Boolean, default=False) # Added captain status joined_at = Column(DateTime, server_default=func.now()) user = relationship("User", back_populates="memberships") - team = relationship("Team", back_populates="memberships") - - -class TeamMember(Base): - __tablename__ = "team_members" - id = Column(Integer, primary_key=True, index=True) - user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - team_id = Column(Integer, ForeignKey("teams.id"), nullable=False) - is_captain = Column(Boolean, default=False) - joined_at = Column(DateTime, server_default=func.now()) - - # Relationships - user = relationship("User", back_populates="teams") team = relationship("Team", back_populates="members") + __table_args__ = (UniqueConstraint('user_id', 'team_id', name='_user_team_uc'),) + class QRSet(Base): """A set of related QR codes, such as codes for different placements in a quiz""" diff --git a/app/templates/email/join_request_response.html b/app/templates/email/join_request_response.html new file mode 100644 index 0000000..43a9dfe --- /dev/null +++ b/app/templates/email/join_request_response.html @@ -0,0 +1,74 @@ + + + + + + Team Join Request Response - LeagueLedger + + + +
+

Team Join Request {{ "Approved" if is_approved else "Denied" }}

+
+ +
+

Hello {{ username }},

+ + {% if is_approved %} +

Good news! Your request to join {{ team_name }} has been approved. You are now a member of the team.

+ {% else %} +

We regret to inform you that your request to join {{ team_name }} has been denied.

+ {% endif %} + +

You can view your teams by visiting your dashboard:

+ +

+ Go to Dashboard +

+ +

Best regards,
The LeagueLedger Team

+
+ + + + diff --git a/app/templates/email/password_reset.html b/app/templates/email/password_reset.html index 99684fb..326de35 100644 --- a/app/templates/email/password_reset.html +++ b/app/templates/email/password_reset.html @@ -45,27 +45,25 @@
-

LeagueLedger Password Reset

+

Password Reset

Hello {{ username }},

-

We received a request to reset your password for your LeagueLedger account. If you didn't make this request, you can safely ignore this email.

+

We received a request to reset your password. If you didn't make this request, you can safely ignore this email.

-

To reset your password, please click the button below:

+

To reset your password, click the button below:

-

- Reset Password -

+
+ Reset Password +
-

Or copy and paste this link into your browser:

-

{{ reset_link }}

+

Or you can copy and paste this link into your browser:

+

{{ reset_url }}

This link will expire in 24 hours.

-

If you have any questions, please contact us at {{ support_email }}

-

Best regards,
The LeagueLedger Team

diff --git a/app/templates/email/team_join_request.html b/app/templates/email/team_join_request.html new file mode 100644 index 0000000..da50b37 --- /dev/null +++ b/app/templates/email/team_join_request.html @@ -0,0 +1,86 @@ + + + + + + Team Join Request - LeagueLedger + + + +
+

Team Join Request

+
+ +
+

Hello {{ captain_name }},

+ +

{{ requester_name }} has requested to join your team {{ team_name }}.

+ + {% if message %} +

Message from {{ requester_name }}:
"{{ message }}"

+ {% endif %} + +

You can approve or deny this request by clicking one of the buttons below:

+ +
+ Approve Request + Deny Request +
+ +

Or you can copy and paste one of these links into your browser:

+

Approve: {{ approve_url }}

+

Deny: {{ deny_url }}

+ +

Best regards,
The LeagueLedger Team

+
+ + + + diff --git a/app/templates/error.html b/app/templates/error.html index 3c9c510..b3b817a 100644 --- a/app/templates/error.html +++ b/app/templates/error.html @@ -1,22 +1,29 @@ {% extends "base.html" %} + {% block content %} -
-
-
- +
+
+
+
-

Oops! Something went wrong

-

{{ error }}

-
- - Go Home +

{{ error|default("An error occurred", true) }}

+ + {% if details %} +

{{ details }}

+ {% endif %} + +
+ + {% if debug_info %} +
+

Debug Information:

+
{{ debug_info }}
+
+ {% endif %}
{% endblock %} diff --git a/app/templates/team_detail.html b/app/templates/team_detail.html index 82448c8..1cee6e6 100644 --- a/app/templates/team_detail.html +++ b/app/templates/team_detail.html @@ -21,11 +21,23 @@ Login to Join {% elif not is_team_member %} -
- -
+
+

Join This Team

+ + {% if is_open %} +

This is an open team. You can join immediately.

+
+ +
+ {% else %} +

This is a closed team. You need to request to join and be approved by a team captain.

+ + Request to Join + + {% endif %} +
{% else %} + +
+ {% else %} +
+

You're requesting to join {{ team.name }}. Your request will need to be approved by a team captain.

+ +
+
+ + +
+ + +
+
+ {% endif %} +
+
+{% endblock %} diff --git a/app/templates/teams/request_processed.html b/app/templates/teams/request_processed.html new file mode 100644 index 0000000..a04cb49 --- /dev/null +++ b/app/templates/teams/request_processed.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} + +{% block title %}Request Processed{% endblock %} + +{% block content %} +
+
+
+
+
+

Team Join Request Processed

+
+
+
+ {{ message }} +
+ +
+
+
+
+
+{% endblock %} diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 84095a6..a391f01 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -1 +1,4 @@ +""" +Utility functions for LeagueLedger. +""" # Utils package initialization diff --git a/app/utils/auth.py b/app/utils/auth.py new file mode 100644 index 0000000..99ee6f3 --- /dev/null +++ b/app/utils/auth.py @@ -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 diff --git a/app/utils/mail.py b/app/utils/mail.py index 1856bab..2fccfba 100644 --- a/app/utils/mail.py +++ b/app/utils/mail.py @@ -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 diff --git a/app/views/admin.py b/app/views/admin.py index 6ebad5e..1db5067 100644 --- a/app/views/admin.py +++ b/app/views/admin.py @@ -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(): diff --git a/app/views/auth.py b/app/views/auth.py index 56d3081..3365e4e 100644 --- a/app/views/auth.py +++ b/app/views/auth.py @@ -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) diff --git a/app/views/convenience.py b/app/views/convenience.py new file mode 100644 index 0000000..373d841 --- /dev/null +++ b/app/views/convenience.py @@ -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) diff --git a/app/views/teams.py b/app/views/teams.py index e2ba0bb..03bb55a 100644 --- a/app/views/teams.py +++ b/app/views/teams.py @@ -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 } ) diff --git a/app/views/teams/__init__.py b/app/views/teams/__init__.py new file mode 100644 index 0000000..2efe459 --- /dev/null +++ b/app/views/teams/__init__.py @@ -0,0 +1,6 @@ +""" +Teams module for LeagueLedger - handles team management functionality. +""" +from .routes import router + +__all__ = ['router'] diff --git a/app/views/teams/actions.py b/app/views/teams/actions.py new file mode 100644 index 0000000..e2e48d5 --- /dev/null +++ b/app/views/teams/actions.py @@ -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 + } + ) diff --git a/app/views/teams/routes.py b/app/views/teams/routes.py new file mode 100644 index 0000000..242427f --- /dev/null +++ b/app/views/teams/routes.py @@ -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) diff --git a/app/views/teams/utils.py b/app/views/teams/utils.py new file mode 100644 index 0000000..636abdc --- /dev/null +++ b/app/views/teams/utils.py @@ -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 diff --git a/app/views/teams/views.py b/app/views/teams/views.py new file mode 100644 index 0000000..d1d6a45 --- /dev/null +++ b/app/views/teams/views.py @@ -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 + } + )