diff --git a/Dockerfile b/Dockerfile index 5d0ac86..88ff350 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,30 @@ -FROM python:3.11-slim-buster +# Use an official Python runtime as a base +FROM python:3.11-slim-bookworm -# Install build dependencies (build-essential provides gcc and other tools) -RUN apt-get update && apt-get install -y build-essential +# Set working directory +WORKDIR /app -WORKDIR /rideaware_landing +# Install system dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* +# Copy requirements first to leverage Docker cache COPY requirements.txt . +# Install Python dependencies RUN pip install --no-cache-dir -r requirements.txt +# Copy application code COPY . . -ENV FLASK_APP=server.py +# Environment variables +ENV FLASK_APP=app.py +ENV FLASK_ENV=production +ENV ENVIRONMENT=production EXPOSE 5001 -CMD ["gunicorn", "--bind", "0.0.0.0:5001", "app:app"] +# Use Gunicorn as production server +CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "4", "--timeout", "120", "app:app"] \ No newline at end of file diff --git a/app.py b/app.py index 8d6188e..85c05bd 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,9 @@ import os -import logging import smtplib from email.mime.text import MIMEText +from functools import wraps +from urllib.parse import urlparse, urljoin + from flask import ( Flask, render_template, @@ -11,135 +13,222 @@ from flask import ( flash, session, ) +from markupsafe import escape from dotenv import load_dotenv from werkzeug.security import check_password_hash -from functools import wraps # Import wraps -from database import get_connection, init_db, get_all_emails, get_admin, create_default_admin + +from database import ( + get_connection, + init_db, + get_all_emails, + get_admin, + create_default_admin, +) load_dotenv() + app = Flask(__name__) app.secret_key = os.getenv("SECRET_KEY") -base_url = os.getenv("BASE_URL") +base_url = os.getenv("BASE_URL", "").strip().strip("/") -# SMTP settings (for sending update emails) SMTP_SERVER = os.getenv("SMTP_SERVER") SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) SMTP_USER = os.getenv("SMTP_USER") SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") -SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) # Use SENDER_EMAIL +SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) -# Logging setup -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) - -# Initialize the database and create default admin user if necessary. init_db() create_default_admin() -# Decorator for requiring login + def login_required(f): - @wraps(f) # Use wraps to preserve function metadata + @wraps(f) def decorated_function(*args, **kwargs): if "username" not in session: - return redirect(url_for("login")) + next_url = request.full_path if request.query_string else request.path + return redirect(url_for("login", next=next_url)) return f(*args, **kwargs) return decorated_function -def send_update_email(subject, body, email): - """Sends email, returns True on success, False on failure.""" +def get_dashboard_counts(): + """Return dict of counts: total subscribers, total newsletters, sent today.""" + counts = {"total_subscribers": 0, "total_newsletters": 0, "sent_today": 0} try: - server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) - server.set_debuglevel(False) # Keep debug level at False for production - server.login(SMTP_USER, SMTP_PASSWORD) - - unsub_link = f"https://{base_url}/unsubscribe?email={email}" - custom_body = ( - f"{body}

" - f"If you ever wish to unsubscribe, please click here" - ) - - msg = MIMEText(custom_body, "html", "utf-8") - msg["Subject"] = subject - msg["From"] = SENDER_EMAIL # Use sender email - msg["To"] = email - - server.sendmail(SENDER_EMAIL, email, msg.as_string()) # Use sender email - - server.quit() - logger.info(f"Update email sent to: {email}") - return True - except Exception as e: - logger.error(f"Failed to send email to {email}: {e}") - return False - - -def process_send_update_email(subject, body): - """Helper function to send an update email to all subscribers.""" - subscribers = get_all_emails() - if not subscribers: - return "No subscribers found." - try: - for email in subscribers: - if not send_update_email(subject, body, email): - return f"Failed to send to {email}" # Specific failure message - - # Log newsletter content for audit purposes conn = get_connection() - cursor = conn.cursor() - cursor.execute( - "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", (subject, body) - ) - conn.commit() - cursor.close() - conn.close() + cur = conn.cursor() + cur.execute("SELECT COUNT(*) FROM subscribers") + counts["total_subscribers"] = cur.fetchone()[0] or 0 + cur.execute("SELECT COUNT(*) FROM newsletters") + counts["total_newsletters"] = cur.fetchone()[0] or 0 + + cur.execute( + """ + SELECT COUNT(*) + FROM newsletters + WHERE sent_at::date = CURRENT_DATE + """ + ) + counts["sent_today"] = cur.fetchone()[0] or 0 + + cur.close() + conn.close() + except Exception: + pass + return counts + + +def is_safe_url(target: str) -> bool: + if not target: + return False + ref = urlparse(request.host_url) + test = urlparse(urljoin(request.host_url, target)) + return test.scheme in ("http", "https") and ref.netloc == test.netloc + + +def send_update_email(subject: str, body_html: str, email: str) -> bool: + """Send a single HTML email with retries.""" + max_retries = 3 + retry_count = 0 + + unsub_link = "" + if base_url: + unsub_link = f"https://{base_url}/unsubscribe?email={email}" + + if unsub_link: + custom_body = ( + f"{body_html}" + f"

" + f"If you ever wish to unsubscribe, please click " + f"here." + ) + else: + custom_body = body_html + + while retry_count < max_retries: + try: + server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) + server.set_debuglevel(0) + server.login(SMTP_USER, SMTP_PASSWORD) + + msg = MIMEText(custom_body, "html", "utf-8") + msg["Subject"] = subject + msg["From"] = SENDER_EMAIL + msg["To"] = email + + server.sendmail(SENDER_EMAIL, [email], msg.as_string()) + server.quit() + return True + except Exception: + retry_count += 1 + if retry_count >= max_retries: + break + import time + + time.sleep(1.0) + + return False + + +def process_send_update_email(subject: str, body_html: str) -> str: + """Send update email to all subscribers and log newsletter content.""" + try: + subscribers = get_all_emails() + if not subscribers: + return "No subscribers found." + + failures = [] + for email in subscribers: + if not send_update_email(subject, body_html, email): + failures.append(email) + + try: + conn = get_connection() + cursor = conn.cursor() + cursor.execute( + "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", + (subject, body_html), + ) + conn.commit() + cursor.close() + conn.close() + except Exception: + pass + + if failures: + return f"Sent with failures: {len(failures)} recipients failed." return "Email has been sent to all subscribers." except Exception as e: - logger.exception("Error processing sending updates") return f"Failed to send email: {e}" -@app.route("/") +@app.route("/", methods=["GET"]) @login_required def index(): - """Displays all subscriber emails""" - emails = get_all_emails() - return render_template("admin_index.html", emails=emails) + """Dashboard: list subscriber emails and show widgets.""" + emails = [] + try: + emails = get_all_emails() + except Exception: + flash("Could not load subscribers right now.", "danger") + + counts = get_dashboard_counts() + return render_template("admin_index.html", emails=emails, counts=counts) + @app.route("/send_update", methods=["GET", "POST"]) @login_required def send_update(): - """Display a form to send an update email; process submission on POST.""" if request.method == "POST": - subject = request.form["subject"] - body = request.form["body"] - result_message = process_send_update_email(subject, body) - flash(result_message) + subject = (request.form.get("subject") or "").strip() + body_html = request.form.get("body") or "" + + if not subject or not body_html: + flash("Subject and body are required", "danger") + return redirect(url_for("send_update")) + + result_message = process_send_update_email(subject, body_html) + flash(escape(result_message)) return redirect(url_for("send_update")) + return render_template("send_update.html") @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": - username = request.form.get("username") - password = request.form.get("password") + username = (request.form.get("username") or "").strip() + password = (request.form.get("password") or "").strip() + + if not username or not password: + flash("Username and password are required", "danger") + return redirect(url_for("login")) + admin = get_admin(username) if admin and check_password_hash(admin[1], password): session["username"] = username + session.permanent = True + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SECURE"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + + next_url = request.args.get("next") + if next_url and is_safe_url(next_url): + flash("Logged in successfully", "success") + return redirect(next_url) + flash("Logged in successfully", "success") return redirect(url_for("index")) else: flash("Invalid username or password", "danger") return redirect(url_for("login")) + return render_template("login.html") -@app.route("/logout") +@app.route("/logout", methods=["GET"]) def logout(): session.pop("username", None) flash("Logged out successfully", "success") @@ -147,4 +236,9 @@ def logout(): if __name__ == "__main__": - app.run(port=5001, debug=True) + is_prod = os.getenv("ENVIRONMENT", "development").lower() == "production" + app.config["PREFERRED_URL_SCHEME"] = "https" if is_prod else "http" + if is_prod: + app.run(host="0.0.0.0", port=5001, debug=False, use_reloader=False) + else: + app.run(host="0.0.0.0", port=5001, debug=True, use_reloader=True) \ No newline at end of file diff --git a/database.py b/database.py index b90e0ef..e5a85fb 100644 --- a/database.py +++ b/database.py @@ -1,54 +1,53 @@ import os -import logging import psycopg2 -from psycopg2 import IntegrityError +from psycopg2 import IntegrityError, pool, OperationalError from dotenv import load_dotenv from werkzeug.security import generate_password_hash load_dotenv() -# Logging setup -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) +try: + DB_MIN_CONN = int(os.getenv("DB_MIN_CONN", 1)) + DB_MAX_CONN = int(os.getenv("DB_MAX_CONN", 10)) + + conn_pool = pool.ThreadedConnectionPool( + minconn=DB_MIN_CONN, + maxconn=DB_MAX_CONN, + 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, + ) +except OperationalError: + raise +except Exception: + raise def get_connection(): - """Return a new connection to the PostgreSQL database.""" - try: - conn = 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, - ) - return conn - except Exception as e: - logger.error(f"Database connection error: {e}") - raise + """Get a connection from the connection pool.""" + return conn_pool.getconn() def init_db(): - """Initialize the database tables.""" + """Initialize database tables with connection pool.""" conn = None + cursor = None try: conn = get_connection() cursor = conn.cursor() - # Create subscribers table (if not exists) cursor.execute( """ CREATE TABLE IF NOT EXISTS subscribers ( id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL ) - """ + """ ) - # Create admin_users table (if not exists) cursor.execute( """ CREATE TABLE IF NOT EXISTS admin_users ( @@ -56,94 +55,96 @@ def init_db(): username TEXT UNIQUE NOT NULL, password TEXT NOT NULL ) - """ + """ ) - # Newsletter storage cursor.execute( """ - CREATE TABLE IF NOT EXISTS newsletters ( - id SERIAL PRIMARY KEY, - subject TEXT NOT NULL, - body TEXT NOT NULL, - sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP - ) - """ + CREATE TABLE IF NOT EXISTS newsletters ( + id SERIAL PRIMARY KEY, + subject TEXT NOT NULL, + body TEXT NOT NULL, + sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP + ) + """ ) conn.commit() - logger.info("Database initialized successfully.") - except Exception as e: - logger.error(f"Database initialization error: {e}") + except Exception: if conn: - conn.rollback() # Rollback if there's an error - + conn.rollback() raise finally: - if conn: + if cursor: cursor.close() - conn.close() + if conn: + conn_pool.putconn(conn) def get_all_emails(): """Return a list of all subscriber emails.""" + conn = None + cursor = None try: conn = get_connection() cursor = conn.cursor() cursor.execute("SELECT email FROM subscribers") results = cursor.fetchall() - emails = [row[0] for row in results] - logger.debug(f"Retrieved emails: {emails}") - return emails - except Exception as e: - logger.error(f"Error retrieving emails: {e}") + return [row[0] for row in results] + except Exception: return [] finally: - if conn: + if cursor: cursor.close() - conn.close() + if conn: + conn_pool.putconn(conn) def add_email(email): """Insert an email into the subscribers table.""" conn = None + cursor = None try: conn = get_connection() cursor = conn.cursor() cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) conn.commit() - logger.info(f"Email {email} added successfully.") return True except IntegrityError: - logger.warning(f"Attempted to add duplicate email: {email}") + if conn: + conn.rollback() return False - except Exception as e: - logger.error(f"Error adding email {email}: {e}") + except Exception: + if conn: + conn.rollback() return False finally: - if conn: + if cursor: cursor.close() - conn.close() + if conn: + conn_pool.putconn(conn) def remove_email(email): """Remove an email from the subscribers table.""" conn = None + cursor = None try: conn = get_connection() cursor = conn.cursor() cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) rowcount = cursor.rowcount conn.commit() - logger.info(f"Email {email} removed successfully.") return rowcount > 0 - except Exception as e: - logger.error(f"Error removing email {email}: {e}") + except Exception: + if conn: + conn.rollback() return False finally: - if conn: + if cursor: cursor.close() - conn.close() + if conn: + conn_pool.putconn(conn) def get_admin(username): @@ -151,6 +152,7 @@ def get_admin(username): Returns a tuple (username, password_hash) if found, otherwise None. """ conn = None + cursor = None try: conn = get_connection() cursor = conn.cursor() @@ -158,15 +160,14 @@ def get_admin(username): "SELECT username, password FROM admin_users WHERE username = %s", (username,), ) - result = cursor.fetchone() - return result # (username, password_hash) - except Exception as e: - logger.error(f"Error retrieving admin: {e}") + return cursor.fetchone() + except Exception: return None finally: - if conn: + if cursor: cursor.close() - conn.close() + if conn: + conn_pool.putconn(conn) def create_default_admin(): @@ -174,30 +175,60 @@ def create_default_admin(): default_username = os.getenv("ADMIN_USERNAME", "admin") default_password = os.getenv("ADMIN_PASSWORD", "changeme") hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") + conn = None + cursor = None try: conn = get_connection() cursor = conn.cursor() - # Check if the admin already exists cursor.execute( "SELECT id FROM admin_users WHERE username = %s", (default_username,) ) - if cursor.fetchone() is None: + exists = cursor.fetchone() + if exists is None: cursor.execute( "INSERT INTO admin_users (username, password) VALUES (%s, %s)", (default_username, hashed_password), ) conn.commit() - logger.info("Default admin created successfully") - else: - logger.info("Default admin already exists") - except Exception as e: - logger.error(f"Error creating default admin: {e}") + except Exception: if conn: conn.rollback() finally: - if conn: + if cursor: cursor.close() - conn.close() + if conn: + conn_pool.putconn(conn) + +def close_pool(): + """Close the database connection pool.""" + try: + conn_pool.closeall() + except Exception: + pass + + +class DatabaseContext: + """Optional context manager for manual transactions.""" + def __init__(self): + self.conn = None + self.cursor = None + + def __enter__(self): + self.conn = get_connection() + self.cursor = self.conn.cursor() + return self.cursor + + def __exit__(self, exc_type, exc_val, exc_tb): + try: + if exc_type: + self.conn.rollback() + else: + self.conn.commit() + finally: + if self.cursor: + self.cursor.close() + if self.conn: + conn_pool.putconn(self.conn) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 26fcaf5..3d2a2e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,6 @@ flask python-dotenv Werkzeug psycopg2-binary +psycopg2-pool +python-decouple +markupsafe \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css index b9c3f88..9dca122 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1,55 +1,306 @@ +:root { + --primary: #2563eb; + --primary-hover: #1d4ed8; + --bg: #f5f7fb; + --bg-grad-1: #f8fbff; + --bg-grad-2: #eef3fb; + --surface: #ffffff; + --text: #0f172a; + --muted: #64748b; + --border: #e5e7eb; + --ring: rgba(37, 99, 235, 0.25); + --radius: 14px; + --shadow-1: 0 8px 24px rgba(15, 23, 42, 0.08); + --shadow-2: 0 14px 38px rgba(15, 23, 42, 0.12); +} + +*, +*::before, +*::after { + box-sizing: border-box; +} +html, body { + margin: 0; + padding: 0; +} body { - font-family: Arial, sans-serif; - padding: 20px; + font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; + color: var(--text); + background: radial-gradient(1000px 650px at 0% 0%, var(--bg-grad-1), transparent 60%), + radial-gradient(900px 600px at 100% 0%, var(--bg-grad-2), transparent 55%), + var(--bg); + min-height: 100vh; +} + +.navbar { + position: sticky; + top: 0; + z-index: 1000; + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid rgba(15, 23, 42, 0.06); +} +.navbar-content { + max-width: 1100px; + margin: 0 auto; + padding: 14px 20px; + display: flex; + align-items: center; + justify-content: space-between; +} +.brand { + color: var(--text); + font-weight: 700; + text-decoration: none; + letter-spacing: 0.2px; +} +.navbar-links a { + color: var(--muted); + text-decoration: none; + margin-left: 14px; + padding: 8px 12px; + border-radius: 10px; + transition: all 0.2s ease; +} +.navbar-links a:hover { + color: var(--text); + background: rgba(15, 23, 42, 0.05); +} +.navbar-links .logout { + color: #b91c1c; +} +.navbar-links .logout:hover { + background: rgba(185, 28, 28, 0.08); +} + +.container { + max-width: 1100px; + margin: 28px auto 48px; + padding: 0 20px; + padding-bottom: 64px; +} + +.footer { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + border-top: 1px solid rgba(15, 23, 42, 0.06); + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(8px); +} + +.footer-inner { + max-width: 1100px; + margin: 0 auto; + padding: 14px 20px; + color: var(--muted); + font-size: 14px; +} + +.page-header { + margin-bottom: 18px; + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} +.page-title { + margin: 0 0 4px 0; + font-size: 26px; + font-weight: 700; +} +.page-subtitle { + margin: 0; + color: var(--muted); + font-size: 14px; +} +.page-actions { + display: flex; + gap: 10px; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-1); + padding: 20px; +} +.empty-state { + text-align: center; + color: var(--muted); +} + +.widgets { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 18px; +} +.widget-card { + background: linear-gradient(180deg, #fff, #fafcff); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-1); + padding: 18px; +} +.widget-label { + color: var(--muted); + font-weight: 600; + font-size: 13px; + margin-bottom: 8px; +} +.widget-value { + font-size: 28px; + font-weight: 800; + letter-spacing: 0.3px; + color: var(--text); +} + +.table-wrap { + overflow: hidden; + border-radius: 12px; + border: 1px solid var(--border); +} +.table { + width: 100%; + border-collapse: collapse; + background: transparent; +} +.table thead th { + text-align: left; + font-weight: 600; + color: var(--muted); + font-size: 13px; + letter-spacing: 0.3px; + background: #f9fafb; + padding: 12px 14px; + border-bottom: 1px solid var(--border); +} +.table tbody td { + padding: 14px; + border-bottom: 1px solid #f1f5f9; +} +.table tbody tr:hover td { + background: #f9fbff; + transition: background 0.15s ease; +} + +.form { + display: grid; + gap: 16px; +} +.form-group { + display: grid; + gap: 8px; +} +.form-group label { + font-weight: 600; + color: #334155; + font-size: 14px; +} +.form-group input, +.form-group textarea { + width: 100%; + color: var(--text); + background: #ffffff; + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 14px; + font-size: 15px; + transition: box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease; +} +.form-group textarea { + resize: vertical; + min-height: 160px; +} +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #a7c2ff; + box-shadow: 0 0 0 4px var(--ring); + background: #ffffff; +} +.form-actions { + display: flex; + gap: 10px; + justify-content: flex-end; +} + +.button { + appearance: none; + border: 1px solid var(--border); + background: #ffffff; + color: var(--text); + border-radius: 12px; + padding: 10px 14px; + cursor: pointer; + font-weight: 600; + transition: transform 0.05s ease, background 0.2s ease, border 0.2s ease; +} +.button:hover { + background: #f5f7fb; +} +.button:active { + transform: translateY(1px); +} +.button-primary { + color: #ffffff; + background: linear-gradient(180deg, #3b82f6, var(--primary)); + border-color: rgba(37, 99, 235, 0.4); +} +.button-primary:hover { + background: linear-gradient(180deg, #2f74ed, var(--primary-hover)); +} +.button-secondary { + background: #ffffff; +} + +.flash-stack { + display: grid; + gap: 10px; + margin-bottom: 18px; +} +.flash { + border-radius: 12px; + padding: 12px 14px; + font-weight: 600; + border: 1px solid var(--border); + background: #ffffff; +} +.flash-success { + border-color: #a7f3d0; + background: #ecfdf5; + color: #065f46; +} +.flash-danger, +.flash-error { + border-color: #fecaca; + background: #fef2f2; + color: #991b1b; +} +.flash-warning { + border-color: #fde68a; + background: #fffbeb; + color: #92400e; +} + +.auth-wrapper { + display: grid; + place-items: center; + min-height: calc(100vh - 120px); + padding-top: 40px; +} +.auth-card { + max-width: 420px; + width: 100%; +} + +@media (max-width: 900px) { + .widgets { + grid-template-columns: 1fr; } - - table { - border-collapse: collapse; - width: 100%; - } - - th, - td { - border: 1px solid #ddd; - padding: 8px; - text-align: left; - } - - th { - background-color: #f2f2f2; - } - - a { - margin-right: 10px; - } - - form { - max-width: 600px; - margin: 0 auto; - } - - label { - display: block; - margin-top: 15px; - } - - input[type="text"], - input[type="password"], - textarea { - width: 100%; - padding: 8px; - } - - button { - margin-top: 15px; - padding: 10px 20px; - } - - .flash { - background-color: #f8d7da; - color: #721c24; - padding: 10px; - margin-bottom: 10px; - text-align: center; - } - \ No newline at end of file +} \ No newline at end of file diff --git a/templates/admin_index.html b/templates/admin_index.html index c4620af..bf57258 100644 --- a/templates/admin_index.html +++ b/templates/admin_index.html @@ -1,44 +1,53 @@ - - - - - - Admin Center - Subscribers - - - +{% extends "base.html" %} +{% block title %}Dashboard{% endblock %} +{% block content %} + -

Subscribers

-

- Send Update Email| - Logout -

+
+
+
Total Subscribers
+
{{ counts.total_subscribers }}
+
+
+
Newsletters Sent
+
{{ counts.total_newsletters }}
+
+
+
Sent Today
+
{{ counts.sent_today }}
+
+
- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% endwith %} - - {% if emails %} - - - - - - - - {% for email in emails %} - - - - {% endfor %} - + {% if emails %} +
+
+
Email Address
{{ email }}
+ + + + + + + {% for email in emails %} + + + + {% endfor %} +
Email Address
{{ email }}
- {% else %} -

No subscribers found.

- {% endif %} - - + + + {% else %} +
+

No subscribers found.

+
+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..13df2b0 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,58 @@ + + + + + + {% block title %}Admin{% endblock %} + + + + + + + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + + {% block content %}{% endblock %} +
+ + + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html index 97fefdd..f70fb06 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,29 +1,34 @@ - - +{% extends "base.html" %} +{% block title %}Admin Login{% endblock %} +{% block content %} +
+
+

Welcome back

+

Sign in to manage your subscribers

- - - - Admin Login - - - - -

Admin Login

- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} - {% for category, message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% endwith %} -
- - - - - -
- - - +
+
+ + +
+
+ + +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/send_update.html b/templates/send_update.html index fccd7ff..e3c16b5 100644 --- a/templates/send_update.html +++ b/templates/send_update.html @@ -1,37 +1,32 @@ - - +{% extends "base.html" %} +{% block title %}Send Update{% endblock %} +{% block content %} + - - - - Admin Center - Send Update - - +
+
+
+ + +
- -

Send Update Email

-

- Back to Subscribers List | - Logout -

- {% with messages = get_flashed_messages() %} - {% if messages %} - {% for message in messages %} -
{{ message }}
- {% endfor %} - {% endif %} - {% endwith %} +
+ + +
- - - - - - - - -
- - - - +
+ +
+ +
+{% endblock %} \ No newline at end of file