diff --git a/Dockerfile b/Dockerfile index 9cdddc7..cc5893a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,52 +1,17 @@ -FROM python:3.11-slim-bullseye +FROM python:3.11-slim-buster -# Install system dependencies -RUN apt-get update && \ - apt-get install -y build-essential curl && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y build-essential -# Set working directory WORKDIR /rideaware_landing -# Copy requirements first for better caching COPY requirements.txt . -# Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt -# Copy application code COPY . . -# Set environment variables ENV FLASK_APP=server.py -ENV PYTHONUNBUFFERED=1 -ENV PYTHONPATH=/rideaware_landing -# Create non-root user for security -RUN useradd --create-home --shell /bin/bash app && \ - chown -R app:app /rideaware_landing -USER app - -# Expose port EXPOSE 5000 -# Health check -HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ - CMD curl -f http://localhost:5000/health || exit 1 - -# Run with optimized Gunicorn settings -CMD ["gunicorn", \ - "--bind", "0.0.0.0:5000", \ - "--workers", "4", \ - "--worker-class", "sync", \ - "--worker-connections", "1000", \ - "--max-requests", "1000", \ - "--max-requests-jitter", "50", \ - "--preload", \ - "--timeout", "30", \ - "--keep-alive", "2", \ - "--access-logfile", "-", \ - "--error-logfile", "-", \ - "--log-level", "info", \ - "server:app"] \ No newline at end of file +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "server:app"] \ No newline at end of file diff --git a/database.py b/database.py index d8059fe..0569f18 100644 --- a/database.py +++ b/database.py @@ -1,222 +1,66 @@ import os import psycopg2 -from psycopg2 import pool, IntegrityError +from psycopg2 import IntegrityError from dotenv import load_dotenv -import logging load_dotenv() -# Global connection pool -_connection_pool = None - -def get_connection_pool(): - """Initialize and return the connection pool""" - global _connection_pool - if _connection_pool is None: - try: - _connection_pool = psycopg2.pool.ThreadedConnectionPool( - minconn=2, - maxconn=20, - host=os.getenv("PG_HOST"), - port=os.getenv("PG_PORT", 5432), - dbname=os.getenv("PG_DATABASE"), - user=os.getenv("PG_USER"), - password=os.getenv("PG_PASSWORD"), - connect_timeout=5 - ) - logging.info("Database connection pool created successfully") - except Exception as e: - logging.error(f"Error creating connection pool: {e}") - raise - return _connection_pool - def get_connection(): - """Get a connection from the pool""" - try: - pool = get_connection_pool() - conn = pool.getconn() - if conn.closed: - # Connection is closed, remove it and get a new one - pool.putconn(conn, close=True) - conn = pool.getconn() - return conn - except Exception as e: - logging.error(f"Error getting connection from pool: {e}") - raise - -def return_connection(conn): - """Return a connection to the pool""" - try: - pool = get_connection_pool() - pool.putconn(conn) - except Exception as e: - logging.error(f"Error returning connection to pool: {e}") - -def close_all_connections(): - """Close all connections in the pool""" - global _connection_pool - if _connection_pool: - _connection_pool.closeall() - _connection_pool = None - logging.info("All database connections closed") - -def column_exists(cursor, table_name, column_name): - """Check if a column exists in a table""" - cursor.execute(""" - SELECT EXISTS ( - SELECT 1 - FROM information_schema.columns - WHERE table_name = %s AND column_name = %s - ) - """, (table_name, column_name)) - return cursor.fetchone()[0] - -def index_exists(cursor, index_name): - """Check if an index exists""" - cursor.execute(""" - SELECT EXISTS ( - SELECT 1 FROM pg_class c - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE c.relname = %s AND n.nspname = 'public' - ) - """, (index_name,)) - return cursor.fetchone()[0] + """Return a database connection.""" + return psycopg2.connect( + host=os.getenv("PG_HOST"), + port=os.getenv("PG_PORT"), + dbname=os.getenv("PG_DATABASE"), + user=os.getenv("PG_USER"), + password=os.getenv("PG_PASSWORD"), + connect_timeout=10 + ) def init_db(): - """Initialize database tables and indexes""" - conn = None - try: - conn = get_connection() - cursor = conn.cursor() + conn = get_connection() + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS subscribers ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL + ) + """) + + cursor.execute(""" + CREATE TABLE IF NOT EXISTS newsletters( + id SERIAL PRIMARY KEY, + subject TEXT NOT NULL, + body TEXT NOT NULL, + sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) - # Create subscribers table - cursor.execute(""" - CREATE TABLE IF NOT EXISTS subscribers ( - id SERIAL PRIMARY KEY, - email TEXT UNIQUE NOT NULL - ) - """) - - # Add created_at column if it doesn't exist - if not column_exists(cursor, 'subscribers', 'created_at'): - cursor.execute(""" - ALTER TABLE subscribers - ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - """) - logging.info("Added created_at column to subscribers table") - - # Create newsletters table - cursor.execute(""" - CREATE TABLE IF NOT EXISTS newsletters( - id SERIAL PRIMARY KEY, - subject TEXT NOT NULL, - body TEXT NOT NULL, - sent_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - """) - - # Create indexes only if they don't exist - if not index_exists(cursor, 'idx_newsletters_sent_at'): - cursor.execute("CREATE INDEX idx_newsletters_sent_at ON newsletters(sent_at DESC)") - logging.info("Created index idx_newsletters_sent_at") - - if not index_exists(cursor, 'idx_subscribers_email'): - cursor.execute("CREATE INDEX idx_subscribers_email ON subscribers(email)") - logging.info("Created index idx_subscribers_email") - - if not index_exists(cursor, 'idx_subscribers_created_at'): - cursor.execute("CREATE INDEX idx_subscribers_created_at ON subscribers(created_at DESC)") - logging.info("Created index idx_subscribers_created_at") - - conn.commit() - cursor.close() - logging.info("Database tables and indexes initialized successfully") - - except Exception as e: - logging.error(f"Error initializing database: {e}") - if conn: - conn.rollback() - raise - finally: - if conn: - return_connection(conn) + conn.commit() + cursor.close() + conn.close() def add_email(email): - """Add email to subscribers with connection pooling""" - conn = None try: - conn = get_connection() - cursor = conn.cursor() - cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) - conn.commit() - cursor.close() - logging.info(f"Email added successfully: {email}") + with get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) + conn.commit() return True - except IntegrityError: - # Email already exists - if conn: - conn.rollback() - logging.info(f"Email already exists: {email}") return False - - except Exception as e: - if conn: - conn.rollback() - logging.error(f"Error adding email {email}: {e}") + except psycopg2.OperationalError as e: + print(f"Error: {e}") return False - - finally: - if conn: - return_connection(conn) def remove_email(email): - """Remove email from subscribers with connection pooling""" - conn = None try: - conn = get_connection() - cursor = conn.cursor() - cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) - conn.commit() - rows_affected = cursor.rowcount - cursor.close() - - if rows_affected > 0: - logging.info(f"Email removed successfully: {email}") - return True - else: - logging.info(f"Email not found for removal: {email}") - return False - + with get_connection() as conn: + with conn.cursor() as cursor: + cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) + conn.commit() + if cursor.rowcount > 0: + return True + return False except Exception as e: - if conn: - conn.rollback() - logging.error(f"Error removing email {email}: {e}") - return False - - finally: - if conn: - return_connection(conn) - -def get_subscriber_count(): - """Get total number of subscribers""" - conn = None - try: - conn = get_connection() - cursor = conn.cursor() - cursor.execute("SELECT COUNT(*) FROM subscribers") - count = cursor.fetchone()[0] - cursor.close() - return count - - except Exception as e: - logging.error(f"Error getting subscriber count: {e}") - return 0 - - finally: - if conn: - return_connection(conn) - -# Cleanup function for graceful shutdown -import atexit -atexit.register(close_all_connections) \ No newline at end of file + print(f"Error removing email: {e}") + return False \ No newline at end of file diff --git a/server.py b/server.py index d1a3dd0..47d4f30 100644 --- a/server.py +++ b/server.py @@ -1,12 +1,11 @@ import os import time -import logging from threading import Thread import smtplib from email.mime.text import MIMEText -from flask import Flask, render_template, request, jsonify, g +from flask import Flask, render_template, request, jsonify from dotenv import load_dotenv -from database import init_db, get_connection, return_connection, add_email, remove_email +from database import init_db, get_connection, add_email, remove_email load_dotenv() @@ -17,14 +16,18 @@ SMTP_PASSWORD = os.getenv('SMTP_PASSWORD') app = Flask(__name__) -# Configure logging -logging.basicConfig(level=logging.INFO) - -# Cache configuration +_db_initialized = False _newsletter_cache = {} _cache_timestamp = {} CACHE_DURATION = 300 +def ensure_db_initialized(): + """Lazy database initialization - only runs on first database access""" + global _db_initialized + if not _db_initialized: + init_db() + _db_initialized = True + def get_newsletters_cached(): """Get newsletters with caching to reduce database hits""" current_time = time.time() @@ -33,32 +36,26 @@ def get_newsletters_cached(): current_time - _cache_timestamp.get('newsletters', 0) < CACHE_DURATION): return _newsletter_cache['newsletters'] - conn = None - try: - conn = get_connection() - cursor = conn.cursor() - cursor.execute( - "SELECT id, subject, body, sent_at " - "FROM newsletters ORDER BY sent_at DESC LIMIT 100" - ) - rows = cursor.fetchall() - cursor.close() - - newsletters = [ - {"id": r[0], "subject": r[1], "body": r[2], "sent_at": r[3]} - for r in rows - ] - - _newsletter_cache['newsletters'] = newsletters - _cache_timestamp['newsletters'] = current_time - - return newsletters - except Exception as e: - app.logger.error(f"Database error in get_newsletters_cached: {e}") - return [] - finally: - if conn: - return_connection(conn) + ensure_db_initialized() + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT id, subject, body, sent_at " + "FROM newsletters ORDER BY sent_at DESC" + ) + rows = cursor.fetchall() + cursor.close() + conn.close() + + newsletters = [ + {"id": r[0], "subject": r[1], "body": r[2], "sent_at": r[3]} + for r in rows + ] + + _newsletter_cache['newsletters'] = newsletters + _cache_timestamp['newsletters'] = current_time + + return newsletters def get_newsletter_by_id_cached(newsletter_id): """Get single newsletter with caching""" @@ -69,38 +66,32 @@ def get_newsletter_by_id_cached(newsletter_id): current_time - _cache_timestamp.get(cache_key, 0) < CACHE_DURATION): return _newsletter_cache[cache_key] - conn = None - try: - conn = get_connection() - cursor = conn.cursor() - cursor.execute( - "SELECT id, subject, body, sent_at " - "FROM newsletters WHERE id = %s", - (newsletter_id,) - ) - row = cursor.fetchone() - cursor.close() - - if not row: - return None - - newsletter = { - "id": row[0], - "subject": row[1], - "body": row[2], - "sent_at": row[3] - } - - _newsletter_cache[cache_key] = newsletter - _cache_timestamp[cache_key] = current_time - - return newsletter - except Exception as e: - app.logger.error(f"Database error in get_newsletter_by_id_cached: {e}") + ensure_db_initialized() + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + "SELECT id, subject, body, sent_at " + "FROM newsletters WHERE id = %s", + (newsletter_id,) + ) + row = cursor.fetchone() + cursor.close() + conn.close() + + if not row: return None - finally: - if conn: - return_connection(conn) + + newsletter = { + "id": row[0], + "subject": row[1], + "body": row[2], + "sent_at": row[3] + } + + _newsletter_cache[cache_key] = newsletter + _cache_timestamp[cache_key] = current_time + + return newsletter def clear_newsletter_cache(): """Clear newsletter cache when data is updated""" @@ -112,49 +103,35 @@ def clear_newsletter_cache(): _cache_timestamp.pop(key, None) @app.before_request -def before_request(): - """Start timing the request and set up request context""" - g.start_time = time.time() +def start_timer(): + request._start_time = time.time() @app.after_request -def after_request(response): - """Log request timing and performance metrics""" - total_time = time.time() - g.start_time - - # Log slow requests - if total_time > 1.0: - app.logger.warning(f"Slow request: {request.method} {request.path} took {total_time:.3f}s") - elif total_time > 0.5: - app.logger.info(f"Request: {request.method} {request.path} took {total_time:.3f}s") - - # Add performance headers for debugging - response.headers['X-Response-Time'] = f"{total_time:.3f}s" - +def log_request(response): + elapsed = time.time() - getattr(request, '_start_time', time.time()) + app.logger.info(f"{request.method} {request.path} completed in {elapsed:.3f}s") return response def send_confirmation_email(to_address: str, unsubscribe_link: str): """ Sends the HTML confirmation email to `to_address`. - This runs inside its own SMTP_SSL connection with reduced timeout. + This runs inside its own SMTP_SSL connection (timeout=10s). """ + subject = "Thanks for subscribing!" + html_body = render_template( + "confirmation_email.html", + unsubscribe_link=unsubscribe_link + ) + + msg = MIMEText(html_body, "html", "utf-8") + msg["Subject"] = subject + msg["From"] = SMTP_USER + msg["To"] = to_address + try: - subject = "Thanks for subscribing!" - html_body = render_template( - "confirmation_email.html", - unsubscribe_link=unsubscribe_link - ) - - msg = MIMEText(html_body, "html", "utf-8") - msg["Subject"] = subject - msg["From"] = SMTP_USER - msg["To"] = to_address - - with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=5) as server: + with smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) as server: server.login(SMTP_USER, SMTP_PASSWORD) server.sendmail(SMTP_USER, [to_address], msg.as_string()) - - app.logger.info(f"Confirmation email sent successfully to {to_address}") - except Exception as e: app.logger.error(f"Failed to send email to {to_address}: {e}") @@ -171,113 +148,65 @@ def index(): @app.route("/subscribe", methods=["POST"]) def subscribe(): - """Subscribe endpoint with optimized database handling""" + """Subscribe endpoint - lazy loads database only when needed""" data = request.get_json() or {} email = data.get("email") - if not email: return jsonify(error="No email provided"), 400 - # Validate email format (basic check) - if "@" not in email or "." not in email.split("@")[-1]: - return jsonify(error="Invalid email format"), 400 + ensure_db_initialized() - try: - if add_email(email): - unsubscribe_link = f"{request.url_root}unsubscribe?email={email}" + if add_email(email): + unsubscribe_link = f"{request.url_root}unsubscribe?email={email}" - # Start email sending in background thread - Thread( - target=send_confirmation_async, - args=(email, unsubscribe_link), - daemon=True - ).start() + Thread( + target=send_confirmation_async, + args=(email, unsubscribe_link), + daemon=True + ).start() - return jsonify(message="Email has been added"), 201 - else: - return jsonify(error="Email already exists"), 400 - - except Exception as e: - app.logger.error(f"Error in subscribe endpoint: {e}") - return jsonify(error="Internal server error"), 500 + return jsonify(message="Email has been added"), 201 + + return jsonify(error="Email already exists"), 400 @app.route("/unsubscribe", methods=["GET"]) def unsubscribe(): - """Unsubscribe endpoint with optimized database handling""" + """Unsubscribe endpoint - lazy loads database only when needed""" email = request.args.get("email") - if not email: return "No email specified.", 400 - try: - if remove_email(email): - return f"The email {email} has been unsubscribed.", 200 - else: - return f"Email {email} was not found or has already been unsubscribed.", 400 - - except Exception as e: - app.logger.error(f"Error in unsubscribe endpoint: {e}") - return "Internal server error", 500 + ensure_db_initialized() + + if remove_email(email): + return f"The email {email} has been unsubscribed.", 200 + return f"Email {email} was not found or has already been unsubscribed.", 400 @app.route("/newsletters", methods=["GET"]) def newsletters(): """ List all newsletters (newest first) with caching for better performance. """ - try: - newsletters = get_newsletters_cached() - return render_template("newsletters.html", newsletters=newsletters) - except Exception as e: - app.logger.error(f"Error in newsletters endpoint: {e}") - return "Internal server error", 500 + newsletters = get_newsletters_cached() + return render_template("newsletters.html", newsletters=newsletters) @app.route("/newsletter/", methods=["GET"]) def newsletter_detail(newsletter_id): """ Show a single newsletter by its ID with caching. """ - try: - newsletter = get_newsletter_by_id_cached(newsletter_id) - - if not newsletter: - return "Newsletter not found.", 404 + newsletter = get_newsletter_by_id_cached(newsletter_id) + + if not newsletter: + return "Newsletter not found.", 404 - return render_template("newsletter_detail.html", newsletter=newsletter) - except Exception as e: - app.logger.error(f"Error in newsletter_detail endpoint: {e}") - return "Internal server error", 500 + return render_template("newsletter_detail.html", newsletter=newsletter) @app.route("/admin/clear-cache", methods=["POST"]) def clear_cache(): """Admin endpoint to clear newsletter cache""" - try: - clear_newsletter_cache() - return jsonify(message="Cache cleared successfully"), 200 - except Exception as e: - app.logger.error(f"Error clearing cache: {e}") - return jsonify(error="Failed to clear cache"), 500 - -@app.route("/health", methods=["GET"]) -def health_check(): - """Health check endpoint for monitoring""" - return jsonify(status="healthy", timestamp=time.time()), 200 - -# Error handlers -@app.errorhandler(404) -def not_found(error): - return jsonify(error="Not found"), 404 - -@app.errorhandler(500) -def internal_error(error): - return jsonify(error="Internal server error"), 500 - -# Initialize database at startup -try: - init_db() - app.logger.info("Database initialized successfully") -except Exception as e: - app.logger.error(f"Failed to initialize database: {e}") - raise + clear_newsletter_cache() + return jsonify(message="Cache cleared successfully"), 200 if __name__ == "__main__": - app.run(host="0.0.0.0", debug=True) + app.run(host="0.0.0.0", debug=True) \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index ac5110b..8232c61 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,8 +6,14 @@ RideAware - Smart Cycling Training Platform - + +