diff --git a/TODO.md b/TODO.md index e0ed7ff..7698450 100644 --- a/TODO.md +++ b/TODO.md @@ -55,10 +55,10 @@ This document outlines upcoming tasks and improvements for the LeagueLedger appl ## User Management & Profile Features - [ ] **Profile Management** - - [ ] Update profile picture functionality - - [ ] Change username capability - - [ ] Account deletion process - - [ ] Profile privacy settings + - [x] Update profile picture functionality + - [x] Change username capability + - [x] Account deletion process + - [x] Profile privacy settings - [ ] Social media integration ## Environment Variables & Configuration @@ -158,4 +158,10 @@ This document outlines upcoming tasks and improvements for the LeagueLedger appl - [ ] Create database migration tools - [ ] Add support for clustering/high availability - [ ] Implement CDN for static assets -- [ ] Create backup and disaster recovery procedures \ No newline at end of file +- [ ] Create backup and disaster recovery procedures + + +## Layout +- [ ] Change layout to use Tailwind CSS +- [ ] Change layout to use https://ui.shadcn.com/ +- [ ] Make mail templates less spam-y \ No newline at end of file diff --git a/app/db_init.py b/app/db_init.py index 8fd13f4..775f0c8 100644 --- a/app/db_init.py +++ b/app/db_init.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta from sqlalchemy import inspect from sqlalchemy.orm import Session from passlib.context import CryptContext +import asyncio from .models import User, Team, TeamMembership, QRCode, QRSet, TeamAchievement, Event, SystemSettings from .db import SessionLocal, engine @@ -26,17 +27,17 @@ def table_has_column(engine, table_name, column_name): columns = [col['name'] for col in inspector.get_columns(table_name)] return column_name in columns -def init_db(): +async def init_db(): """Initialize the database, applying migrations and seeding data.""" # First, create all tables if they don't exist from .models import Base Base.metadata.create_all(bind=engine) # Then, run any needed migrations - run_migrations(engine) + await asyncio.to_thread(run_migrations, engine) # Then proceed with seeding if needed - seed_db() + await asyncio.to_thread(seed_db) # Initialize system settings if needed - init_system_settings() + await asyncio.to_thread(init_system_settings) def init_system_settings(): """Initialize the system settings table if it doesn't exist.""" @@ -173,23 +174,24 @@ def seed_db(): memberships = [] membership_data = [ # Quiz Wizards - (1, 1, True, 160), # Admin user is team admin of Quiz Wizards - (2, 1, True, 155), - (3, 1, False, 130), - (4, 1, False, 90), - (5, 1, False, 45), + (1, 1, True, True, 160), # Admin user is team admin AND captain of Quiz Wizards + (2, 1, True, True, 155), # John is also admin AND captain + (3, 1, False, False, 130), + (4, 1, False, False, 90), + (5, 1, False, False, 45), # Trivia Titans - (2, 2, True, 150), - (1, 2, False, 145), + (2, 2, True, True, 150), # John is admin AND captain of Trivia Titans + (1, 2, False, False, 145), # Beer Brainiacs - (3, 3, True, 120), + (3, 3, True, True, 120), # Sarah is admin AND captain of Beer Brainiacs ] - for user_id, team_id, is_admin, days_ago in membership_data: + for user_id, team_id, is_admin, is_captain, days_ago in membership_data: membership_attrs = { "user_id": user_id, "team_id": team_id, - "is_admin": is_admin + "is_admin": is_admin, + "is_captain": is_captain # Added is_captain field } if has_joined_at: membership_attrs["joined_at"] = datetime.now() - timedelta(days=days_ago) @@ -366,4 +368,4 @@ def seed_db(): db.close() if __name__ == "__main__": - init_db() + asyncio.run(init_db()) diff --git a/app/main.py b/app/main.py index c0af7a3..56ed8db 100644 --- a/app/main.py +++ b/app/main.py @@ -11,6 +11,11 @@ from starlette.middleware.authentication import AuthenticationMiddleware from dotenv import load_dotenv import logging import contextlib +import time +import asyncio +import sqlalchemy +from sqlalchemy.exc import OperationalError, ProgrammingError +from sqlalchemy import text from .db import engine, get_db from . import models @@ -52,10 +57,54 @@ app.add_middleware( # Then add SessionMiddleware (last added = first executed) app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY) -# Initialize database on startup -@app.on_event("startup") -async def startup_db_client(): - logger.info("Starting database initialization") +# Function to check database connection +async def check_db_connection(max_retries=10, initial_retry_delay=1): + """ + Check database connection with retries and exponential backoff + + Args: + max_retries: Maximum number of retry attempts + initial_retry_delay: Initial delay before first retry in seconds + + Returns: + bool: True if connection succeeded, False otherwise + """ + retry_delay = initial_retry_delay + + for attempt in range(1, max_retries + 1): + try: + # Try a simple query to test the connection + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + logger.info(f"Database connection successful on attempt {attempt}") + return True + except (OperationalError, ProgrammingError) as e: + if "Connection refused" in str(e) or "Can't connect" in str(e): + logger.warning(f"Database connection attempt {attempt}/{max_retries} failed: {str(e)}") + + if attempt < max_retries: + logger.info(f"Retrying in {retry_delay} seconds...") + await asyncio.sleep(retry_delay) + # Exponential backoff with a maximum delay of 10 seconds + retry_delay = min(retry_delay * 2, 10) + else: + logger.error(f"Failed to connect to database after {max_retries} attempts") + return False + else: + # For other database errors, log and continue + logger.error(f"Database error: {str(e)}") + return False + except Exception as e: + logger.error(f"Unexpected error connecting to database: {str(e)}") + return False + + return False + +# Initialize database with schema and tables +async def initialize_database(): + """ + Initialize the database schema and seed data + """ try: # Import models to ensure they're registered with Base before initialization from . import models @@ -63,10 +112,29 @@ async def startup_db_client(): # Initialize database (applies migrations and seeds data) init_db() logger.info("Database initialized and migrated successfully") - + return True except Exception as e: logger.error(f"Database initialization error: {str(e)}") - # We continue even if there was an error, as the application might still work with partial functionality + return False + +# Initialize database on startup +@app.on_event("startup") +async def startup_db_client(): + logger.info("Starting database initialization") + + # First, ensure database server is available with polling + max_retries = 15 # Try up to 15 times (with exponential backoff, this could be ~2 minutes) + connected = await check_db_connection(max_retries=max_retries) + + if connected: + # Once connected, initialize the database + success = await initialize_database() + if success: + logger.info("Database setup completed successfully") + else: + logger.warning("Database initialization completed with errors") + else: + logger.error("Failed to connect to database, application may not function correctly") # Mount static files app.mount("/static", StaticFiles(directory="app/static"), name="static") diff --git a/app/templates/email/email_verification.html b/app/templates/email/email_verification.html index 67c5a98..a561a8e 100644 --- a/app/templates/email/email_verification.html +++ b/app/templates/email/email_verification.html @@ -12,62 +12,92 @@ max-width: 600px; margin: 0 auto; padding: 20px; + background-color: #f9f9f9; + } + .container { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + overflow: hidden; } .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; + padding: 30px; + background-color: #ffffff; + } + .button-container { + margin: 25px 0; + text-align: center; } .button { display: inline-block; background-color: #2D7738; color: white; text-decoration: none; - padding: 10px 20px; + padding: 12px 25px; border-radius: 5px; - margin: 20px 0; + font-weight: 500; + } + .link-help { + margin-top: 20px; + padding: 15px; + background-color: #f5f5f5; + border-radius: 5px; + font-size: 14px; } .footer { - margin-top: 20px; - font-size: 12px; + padding: 20px; text-align: center; + font-size: 12px; color: #777; + border-top: 1px solid #eaeaea; } -
-

Verify Your Email Address

-
- -
-

Hello {{ username }},

+
+
+

Email Verification

+
-

Thank you for registering with LeagueLedger! To complete your registration, please verify your email address by clicking the button below:

+
+

Hello {{ username }},

+ +

Thank you for creating an account with LeagueLedger, your pub quiz team tracking platform. We're excited to have you join our community!

+ +

To ensure account security and complete your registration, please verify your email address by clicking the button below:

+ + + + + +

This verification step helps us keep your account secure and allows you to:

+
    +
  • Create or join teams
  • +
  • Track your quiz night scores
  • +
  • Earn points and achievements
  • +
  • Climb the leaderboards
  • +
+ +

If you did not create an account with LeagueLedger, please disregard this email.

+ +

Best regards,
The LeagueLedger Team

+
-

- Verify Email -

- -

Or copy and paste this link into your browser:

-

{{ verification_link }}

- -

If you did not create an account with us, please ignore this email.

- -

Best regards,
The LeagueLedger Team

-
- - diff --git a/app/templates/email/join_request_response.html b/app/templates/email/join_request_response.html index 43a9dfe..c3faa2c 100644 --- a/app/templates/email/join_request_response.html +++ b/app/templates/email/join_request_response.html @@ -3,7 +3,7 @@ - Team Join Request Response - LeagueLedger + Team Join Request Update - LeagueLedger -
-

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

-
- -
-

Hello {{ username }},

+
+
+

Team Request {{ "Approved" if is_approved else "Status Update" }}

+
- {% 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 %} +
+

Hello {{ username }},

+ + {% if is_approved %} +
+

Welcome to the team!

+

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

+
+ +

As a team member, you can now:

+
    +
  • Participate in team events
  • +
  • Contribute to your team's score
  • +
  • Earn points and achievements together
  • +
  • Track your team's performance on the leaderboard
  • +
+ +

Visit your dashboard to see your team's upcoming events and current standings.

+ {% else %} +
+

Your request to join {{ team_name }} has not been approved at this time.

+

Don't worry - there are many teams in LeagueLedger that might be a better fit for you.

+
+ +
+

What's next?

+

You can:

+
    +
  • Request to join another team
  • +
  • Create your own team
  • +
  • Explore upcoming pub quiz events in your area
  • +
+
+ {% endif %} + + + +

Thank you for being part of the LeagueLedger community!

+ +

Best regards,
The LeagueLedger Team

+
-

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 326de35..3d8e3d8 100644 --- a/app/templates/email/password_reset.html +++ b/app/templates/email/password_reset.html @@ -12,64 +12,99 @@ max-width: 600px; margin: 0 auto; padding: 20px; + background-color: #f9f9f9; + } + .container { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + overflow: hidden; } .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; + padding: 30px; + background-color: #ffffff; + } + .security-note { + padding: 15px; + margin: 20px 0; + background-color: #fff8e1; + border-left: 3px solid #ffd54f; + border-radius: 5px; + } + .button-container { + margin: 25px 0; + text-align: center; } .button { display: inline-block; background-color: #2D7738; color: white; text-decoration: none; - padding: 10px 20px; + padding: 12px 25px; border-radius: 5px; - margin: 20px 0; + font-weight: 500; + } + .link-help { + margin-top: 20px; + padding: 15px; + background-color: #f5f5f5; + border-radius: 5px; + font-size: 14px; } .footer { - margin-top: 20px; - font-size: 12px; + padding: 20px; text-align: center; + font-size: 12px; color: #777; + border-top: 1px solid #eaeaea; } -
-

Password Reset

-
- -
-

Hello {{ username }},

- -

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, click the button below:

- -
- Reset Password +
+
+

Password Reset

-

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

-

{{ reset_url }}

+
+

Hello {{ username }},

+ +

We received a request to reset your password for your LeagueLedger account. You can set a new password by clicking the button below:

+ + + +
+

Security Note: This link will expire in 24 hours for your protection. If you did not request a password reset, please disregard this email and consider reviewing your account security.

+
+ + + +

With LeagueLedger, you can continue to:

+
    +
  • Track your team's progress in pub quiz events
  • +
  • View your rank on the leaderboard
  • +
  • Manage your team memberships
  • +
  • Redeem QR codes for points
  • +
+ +

Best regards,
The LeagueLedger Team

+
-

This link will expire in 24 hours.

- -

Best regards,
The LeagueLedger Team

-
- - diff --git a/app/templates/email/team_join_request.html b/app/templates/email/team_join_request.html index da50b37..d9af240 100644 --- a/app/templates/email/team_join_request.html +++ b/app/templates/email/team_join_request.html @@ -12,19 +12,27 @@ max-width: 600px; margin: 0 auto; padding: 20px; + background-color: #f9f9f9; + } + .container { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + overflow: hidden; } .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; + padding: 30px; + background-color: #ffffff; + } + .button-container { + margin: 25px 0; + text-align: center; } .button { display: inline-block; @@ -33,54 +41,78 @@ text-decoration: none; padding: 10px 20px; border-radius: 5px; - margin: 20px 0; + font-weight: 500; } .button.approve { - background-color: #4CAF50; + background-color: #2D7738; } .button.deny { - background-color: #f44336; + background-color: #9e9e9e; margin-left: 10px; } - .footer { + .link-help { margin-top: 20px; - font-size: 12px; + padding: 15px; + background-color: #f5f5f5; + border-radius: 5px; + font-size: 14px; + } + .footer { + padding: 20px; text-align: center; + font-size: 12px; color: #777; + border-top: 1px solid #eaeaea; + } + .message-box { + margin: 15px 0; + padding: 15px; + background-color: #f5f5f5; + border-radius: 5px; + border-left: 3px solid #2D7738; } -
-

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 +
+
+

Team Join Request

-

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

-

Approve: {{ approve_url }}

-

Deny: {{ deny_url }}

+
+

Hello {{ captain_name }},

+ +

We hope this email finds you well. {{ requester_name }} has requested to join your team {{ team_name }} on LeagueLedger.

+ + {% if message %} +
+

Message from {{ requester_name }}:

+

"{{ message }}"

+
+ {% endif %} + +

As the team captain, you can review this request and decide whether to approve or deny it.

+ + + + + +

Thank you for being an active team captain in our community!

+ +

Best regards,
The LeagueLedger Team

+
-

Best regards,
The LeagueLedger Team

-
- - diff --git a/app/templates/email/welcome.html b/app/templates/email/welcome.html index 1806fce..b8d2314 100644 --- a/app/templates/email/welcome.html +++ b/app/templates/email/welcome.html @@ -12,67 +12,101 @@ max-width: 600px; margin: 0 auto; padding: 20px; + background-color: #f9f9f9; + } + .container { + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + overflow: hidden; } .header { background-color: #2D7738; - padding: 20px; + padding: 25px; text-align: center; color: white; - border-radius: 5px 5px 0 0; } .content { + padding: 30px; + background-color: #ffffff; + } + .feature-list { + background-color: #f5f5f5; padding: 20px; - border: 1px solid #ddd; - border-top: none; - border-radius: 0 0 5px 5px; + margin: 20px 0; + border-radius: 8px; + } + .button-container { + margin: 25px 0; + text-align: center; } .button { display: inline-block; background-color: #2D7738; color: white; text-decoration: none; - padding: 10px 20px; + padding: 12px 25px; border-radius: 5px; - margin: 20px 0; + font-weight: 500; + } + .getting-started { + margin-top: 20px; + padding: 15px; + background-color: #e8f5e9; + border-radius: 5px; + border-left: 4px solid #2D7738; } .footer { - margin-top: 20px; - font-size: 12px; + padding: 20px; text-align: center; + font-size: 12px; color: #777; + border-top: 1px solid #eaeaea; } -
-

Welcome to LeagueLedger!

-
- -
-

Hello {{ username }},

+
+
+

Welcome to LeagueLedger!

+
-

Welcome to LeagueLedger! We're excited to have you join our community.

+
+

Hello {{ username }},

+ +

Thank you for joining LeagueLedger! We're delighted to have you as part of our community of pub quiz enthusiasts.

+ +
+

With your new account, you can:

+
    +
  • Join or create teams - Connect with friends and form your pub quiz dream team
  • +
  • Track your progress - Keep a record of all your quiz results in one place
  • +
  • Earn achievements - Get recognition for your team's accomplishments
  • +
  • Compete on leaderboards - See how your team ranks against others
  • +
  • Scan QR codes - Easily record your points after quiz nights
  • +
+
+ +
+

Getting Started

+

The best way to begin is to either join an existing team or create your own. Visit your dashboard to get started!

+
+ + + +

If you have any questions or need assistance, you can reply to this email or visit our help center.

+ +

We're excited to see you and your team climb the leaderboards!

+ +

Best regards,
The LeagueLedger Team

+
-

With LeagueLedger, you can:

-
    -
  • Track your team's progress
  • -
  • Participate in events
  • -
  • Collect points and earn achievements
  • -
  • Connect with other teams and players
  • -
- -

If you have any questions or need assistance, feel free to reach out to our support team.

- -

- Go to Dashboard -

- -

Best regards,
The LeagueLedger Team

-
- - diff --git a/app/utils/mail.py b/app/utils/mail.py index 2fccfba..8a1eca4 100644 --- a/app/utils/mail.py +++ b/app/utils/mail.py @@ -1,15 +1,21 @@ """ -Email utility module for LeagueLedger using FastAPI-Mail +Email utility module for LeagueLedger using Python's built-in email and smtplib packages """ import os +import smtplib +import asyncio +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import formatdate from pathlib import Path -from typing import List, Dict, Any, Optional +from typing import List, Dict, Any, Optional, Union 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 +import re +from bs4 import BeautifulSoup # Setup logging logger = logging.getLogger(__name__) @@ -26,51 +32,130 @@ 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 -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 -) +def html_to_plain_text(html_content): + """ + Convert HTML content to plain text for email alternatives. + This helps improve email deliverability by providing a plain text version. + """ + if not html_content: + return "" + + # Use BeautifulSoup to parse HTML + try: + soup = BeautifulSoup(html_content, 'html.parser') + + # Get text from HTML + text = soup.get_text(separator='\n', strip=True) + + # Clean up extra whitespace and handle links + text = re.sub(r'\n\s+\n', '\n\n', text) + text = re.sub(r'\n{3,}', '\n\n', text) + + return text + except Exception as e: + logger.error(f"Error converting HTML to plain text: {str(e)}") + # Fallback: Basic HTML tag removal + plain = re.sub('<.*?>', '', html_content) + return plain.replace(' ', ' ').strip() -async def send_email( - email_to: List[EmailStr], +async def _send_email_async( + email_to: Union[str, List[str]], subject: str, html_content: str, - background_tasks: BackgroundTasks -): + plain_text_content: Optional[str] = None +) -> bool: + """ + Asynchronous function to send emails using Python's built-in email and smtplib packages. + This function handles the actual sending of the email. + """ + # Convert single email to list if needed + recipients = [email_to] if isinstance(email_to, str) else email_to + + # Generate plain text from HTML if not provided + if plain_text_content is None: + plain_text_content = html_to_plain_text(html_content) + + try: + # Create multipart message container + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = f"{MAIL_FROM_NAME} <{MAIL_FROM}>" + msg['To'] = ", ".join(recipients) + msg['Date'] = formatdate(localtime=True) + + # Attach plain text part first (will be displayed if HTML not supported) + # RFC 2046 defines that the last part is preferred + part1 = MIMEText(plain_text_content, 'plain', 'utf-8') + msg.attach(part1) + + # Attach HTML part last (will be preferred by most email clients) + part2 = MIMEText(html_content, 'html', 'utf-8') + msg.attach(part2) + + # Run SMTP connection in a separate thread to avoid blocking + result = await asyncio.to_thread(_send_smtp_email, msg, recipients) + return result + except Exception as e: + logger.error(f"Failed to send email: {str(e)}") + return False + +def _send_smtp_email(msg, recipients): + """ + Helper function to handle SMTP connection and sending. + This runs in a separate thread to avoid blocking the main event loop. + """ + try: + # Choose the appropriate SMTP connection method + if MAIL_SSL_TLS: + server = smtplib.SMTP_SSL(MAIL_SERVER, MAIL_PORT) + else: + server = smtplib.SMTP(MAIL_SERVER, MAIL_PORT) + + # Use STARTTLS if configured + if MAIL_STARTTLS: + server.starttls() + + # Login if credentials provided + if MAIL_USERNAME and MAIL_PASSWORD: + server.login(MAIL_USERNAME, MAIL_PASSWORD) + + # Send email + server.send_message(msg) + server.quit() + + logger.info(f"Email sent successfully to {recipients}") + return True + except Exception as e: + logger.error(f"SMTP error: {str(e)}") + return False + +async def send_email( + email_to: Union[str, List[str]], + subject: str, + html_content: str, + background_tasks: BackgroundTasks, + plain_text_content: Optional[str] = None +) -> bool: """Generic function to send emails""" try: - message = MessageSchema( + # Add email sending to background tasks + background_tasks.add_task( + _send_email_async, + email_to=email_to, subject=subject, - recipients=[email_to] if isinstance(email_to, str) else email_to, - body=html_content, - subtype="html" + html_content=html_content, + plain_text_content=plain_text_content ) - - 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: - logger.error(f"Failed to send email: {str(e)}") + logger.error(f"Failed to queue email: {str(e)}") return False async def send_password_reset_email( @@ -178,12 +263,37 @@ async def send_verification_email( verification_link=verification_link ) - # Send email + # Generate plain text version explicitly + plain_text = f""" +Hello {username}, + +Thank you for creating an account with LeagueLedger, your pub quiz team tracking platform. We're excited to have you join our community! + +To ensure account security and complete your registration, please verify your email address by visiting this link: +{verification_link} + +This verification step helps us keep your account secure and allows you to: +- Create or join teams +- Track your quiz night scores +- Earn points and achievements +- Climb the leaderboards + +If you did not create an account with LeagueLedger, please disregard this email. + +Best regards, +The LeagueLedger Team + +© 2025 LeagueLedger. All rights reserved. +This email was sent to verify your account registration. +""" + + # Send email with both HTML and plain text versions subject = "Verify Your Email Address - LeagueLedger" await send_email( email_to=recipient_email, subject=subject, html_content=html_content, + plain_text_content=plain_text, background_tasks=background_tasks ) logger.info(f"Verification email sent to {recipient_email}") @@ -226,3 +336,37 @@ async def send_join_request_response( except Exception as e: logger.error(f"Failed to send join request response email: {str(e)}") return False + +async def send_welcome_email( + email_to: str, + username: str, + background_tasks: BackgroundTasks +): + """Send welcome email to newly registered users after verification""" + try: + # Get the email template + template = env.get_template("email/welcome.html") + + # Render the HTML content with variables + html_content = template.render( + username=username, + base_url=APP_BASE_URL + ) + + # Generate plain text version + plain_text_content = html_to_plain_text(html_content) + + # Send email + subject = "Welcome to LeagueLedger!" + await send_email( + email_to=email_to, + subject=subject, + html_content=html_content, + plain_text_content=plain_text_content, + background_tasks=background_tasks + ) + logger.info(f"Welcome email sent to {email_to}") + return True + except Exception as e: + logger.error(f"Failed to send welcome email: {str(e)}") + return False diff --git a/app/views/auth.py b/app/views/auth.py index 94dd495..f5d483d 100644 --- a/app/views/auth.py +++ b/app/views/auth.py @@ -220,6 +220,7 @@ async def register_post( @router.get("/verify-email", response_class=HTMLResponse) async def verify_email( request: Request, + background_tasks: BackgroundTasks, token: str, db: Session = Depends(get_db) ): @@ -243,11 +244,10 @@ async def verify_email( # Send welcome email in the background try: from ..utils.mail import send_welcome_email - background_tasks = BackgroundTasks() - background_tasks.add_task( - send_welcome_email, - email=user.email, - username=user.username + await send_welcome_email( + email_to=user.email, + username=user.username, + background_tasks=background_tasks ) except Exception as e: print(f"Failed to queue welcome email: {str(e)}") @@ -818,7 +818,7 @@ async def forgot_password_post( try: # Send reset email await send_password_reset_email( - email=user.email, + email_to=user.email, username=user.username, reset_token=reset_token, background_tasks=background_tasks, diff --git a/app/views/teams.py b/app/views/teams.py index ec2a970..1ba9c0c 100644 --- a/app/views/teams.py +++ b/app/views/teams.py @@ -13,7 +13,7 @@ from starlette.status import HTTP_303_SEE_OTHER from starlette.middleware.sessions import SessionMiddleware from ..db import SessionLocal, get_db -from ..models import Team, TeamMembership, User, QRCode, TeamAchievement, TeamMember, TeamJoinRequest +from ..models import Team, TeamMembership, User, QRCode, TeamAchievement, TeamJoinRequest from ..schemas import TeamCreate from ..templates_config import templates from ..utils.auth import get_current_user, is_team_captain @@ -90,28 +90,21 @@ async def create_team_post( name=name, description=description, logo_url=logo_url, - is_open=is_open # Save is_open value + is_open=is_open, # Save is_open value + owner_id=current_user.id # Set the owner_id field ) db.add(team) db.commit() db.refresh(team) - # Make the user an admin of the team in TeamMembership + # Make the user an admin and captain of the team in TeamMembership team_membership = TeamMembership( user_id=current_user.id, team_id=team.id, - is_admin=True # User becomes admin of the team + is_admin=True, + is_captain=True # User becomes both admin and captain of the team ) db.add(team_membership) - - # 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=HTTP_303_SEE_OTHER) @@ -173,9 +166,9 @@ async def join_team_page( ) # Check if user is already a member - existing_membership = db.query(TeamMember).filter( - TeamMember.team_id == team_id, - TeamMember.user_id == current_user.id + existing_membership = db.query(TeamMembership).filter( + TeamMembership.team_id == team_id, + TeamMembership.user_id == current_user.id ).first() if existing_membership: @@ -232,9 +225,9 @@ async def join_team_request( ) # Check if user is already a member - existing_membership = db.query(TeamMember).filter( - TeamMember.team_id == team_id, - TeamMember.user_id == current_user.id + existing_membership = db.query(TeamMembership).filter( + TeamMembership.team_id == team_id, + TeamMembership.user_id == current_user.id ).first() if existing_membership: @@ -243,10 +236,11 @@ async def join_team_request( # Open team - directly add the user if team.is_open: # Add user to team - new_member = TeamMember( + new_member = TeamMembership( team_id=team_id, user_id=current_user.id, - is_captain=False # Use is_captain instead of role + is_captain=False, + is_admin=False ) db.add(new_member) @@ -286,9 +280,9 @@ async def join_team_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 + captains = db.query(User).join(TeamMembership).filter( + TeamMembership.team_id == team_id, + TeamMembership.is_captain == True ).all() if not captains: @@ -352,17 +346,10 @@ def direct_join_team(request: Request, team_id: int, db: Session = Depends(get_d new_member = TeamMembership( user_id=user_id, team_id=team.id, - is_admin=False + is_admin=False, + is_captain=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 diff --git a/docker-compose.yml b/docker-compose.yml index 5218c65..353a207 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -110,7 +110,7 @@ services: container_name: pubquiz_mailpit restart: unless-stopped ports: - - "8025:8025" # Web UI + - "8025:8025" # Web UI - Access emails at http://localhost:8025/ - "1025:1025" # SMTP Server environment: MH_STORAGE: "memory" # Store emails in memory (they will be lost on container restart) diff --git a/requirements.txt b/requirements.txt index 3835f65..4c3fbd6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,7 +34,7 @@ psutil>=5.9.0 # System monitoring and statistics python-dateutil>=2.8.2 # Date manipulation utilities # Email support -fastapi-mail>=1.4.2 +beautifulsoup4>=4.12.2 # HTML parsing for emails # Image processing library for QR code generation Pillow>=9.0.0