Compare commits
	
		
			10 commits
		
	
	
		
			refactor/c
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 803471b914 | ||
|   | 797266226b | ||
|   | 17fe0bf79f | ||
|   | f2d225c56a | ||
|   | 941a3dabc9 | ||
|   | 49ab3c1fe4 | ||
|   | 4f059fd0e1 | ||
|   | d9c86aa1bb | ||
|   | a8589b659f | ||
|   | e36bdc4568 | 
					 9 changed files with 769 additions and 311 deletions
				
			
		
							
								
								
									
										24
									
								
								Dockerfile
									
										
									
									
									
								
							
							
						
						
									
										24
									
								
								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) | # Set working directory | ||||||
| RUN apt-get update && apt-get install -y build-essential | 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 . | COPY requirements.txt . | ||||||
| 
 | 
 | ||||||
|  | # Install Python dependencies | ||||||
| RUN pip install --no-cache-dir -r requirements.txt | RUN pip install --no-cache-dir -r requirements.txt | ||||||
| 
 | 
 | ||||||
|  | # Copy application code | ||||||
| COPY . . | COPY . . | ||||||
| 
 | 
 | ||||||
| ENV FLASK_APP=server.py | # Environment variables | ||||||
|  | ENV FLASK_APP=app.py | ||||||
|  | ENV FLASK_ENV=production | ||||||
|  | ENV ENVIRONMENT=production | ||||||
| 
 | 
 | ||||||
| EXPOSE 5001 | 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"] | ||||||
							
								
								
									
										244
									
								
								app.py
									
										
									
									
									
								
							
							
						
						
									
										244
									
								
								app.py
									
										
									
									
									
								
							|  | @ -1,7 +1,9 @@ | ||||||
| import os | import os | ||||||
| import logging |  | ||||||
| import smtplib | import smtplib | ||||||
| from email.mime.text import MIMEText | from email.mime.text import MIMEText | ||||||
|  | from functools import wraps | ||||||
|  | from urllib.parse import urlparse, urljoin | ||||||
|  | 
 | ||||||
| from flask import ( | from flask import ( | ||||||
|     Flask, |     Flask, | ||||||
|     render_template, |     render_template, | ||||||
|  | @ -11,135 +13,222 @@ from flask import ( | ||||||
|     flash, |     flash, | ||||||
|     session, |     session, | ||||||
| ) | ) | ||||||
|  | from markupsafe import escape | ||||||
| from dotenv import load_dotenv | from dotenv import load_dotenv | ||||||
| from werkzeug.security import check_password_hash | 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() | load_dotenv() | ||||||
|  | 
 | ||||||
| app = Flask(__name__) | app = Flask(__name__) | ||||||
| app.secret_key = os.getenv("SECRET_KEY") | 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_SERVER = os.getenv("SMTP_SERVER") | ||||||
| SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) | SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) | ||||||
| SMTP_USER = os.getenv("SMTP_USER") | SMTP_USER = os.getenv("SMTP_USER") | ||||||
| SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") | 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() | init_db() | ||||||
| create_default_admin() | create_default_admin() | ||||||
| 
 | 
 | ||||||
| # Decorator for requiring login | 
 | ||||||
| def login_required(f): | def login_required(f): | ||||||
|     @wraps(f)  # Use wraps to preserve function metadata |     @wraps(f) | ||||||
|     def decorated_function(*args, **kwargs): |     def decorated_function(*args, **kwargs): | ||||||
|         if "username" not in session: |         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 f(*args, **kwargs) | ||||||
| 
 | 
 | ||||||
|     return decorated_function |     return decorated_function | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def send_update_email(subject, body, email): | def get_dashboard_counts(): | ||||||
|     """Sends email, returns True on success, False on failure.""" |     """Return dict of counts: total subscribers, total newsletters, sent today.""" | ||||||
|  |     counts = {"total_subscribers": 0, "total_newsletters": 0, "sent_today": 0} | ||||||
|     try: |     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}<br><br>" |  | ||||||
|             f"If you ever wish to unsubscribe, please click <a href='{unsub_link}'>here</a>" |  | ||||||
|         ) |  | ||||||
| 
 |  | ||||||
|         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() |         conn = get_connection() | ||||||
|         cursor = conn.cursor() |         cur = conn.cursor() | ||||||
|         cursor.execute( |         cur.execute("SELECT COUNT(*) FROM subscribers") | ||||||
|             "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", (subject, body) |         counts["total_subscribers"] = cur.fetchone()[0] or 0 | ||||||
|         ) |  | ||||||
|         conn.commit() |  | ||||||
|         cursor.close() |  | ||||||
|         conn.close() |  | ||||||
| 
 | 
 | ||||||
|  |         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"<br><br>" | ||||||
|  |             f"If you ever wish to unsubscribe, please click " | ||||||
|  |             f"<a href='{unsub_link}'>here</a>." | ||||||
|  |         ) | ||||||
|  |     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." |         return "Email has been sent to all subscribers." | ||||||
|     except Exception as e: |     except Exception as e: | ||||||
|         logger.exception("Error processing sending updates") |  | ||||||
|         return f"Failed to send email: {e}" |         return f"Failed to send email: {e}" | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.route("/") | @app.route("/", methods=["GET"]) | ||||||
| @login_required | @login_required | ||||||
| def index(): | def index(): | ||||||
|     """Displays all subscriber emails""" |     """Dashboard: list subscriber emails and show widgets.""" | ||||||
|     emails = get_all_emails() |     emails = [] | ||||||
|     return render_template("admin_index.html", emails=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"]) | @app.route("/send_update", methods=["GET", "POST"]) | ||||||
| @login_required | @login_required | ||||||
| def send_update(): | def send_update(): | ||||||
|     """Display a form to send an update email; process submission on POST.""" |  | ||||||
|     if request.method == "POST": |     if request.method == "POST": | ||||||
|         subject = request.form["subject"] |         subject = (request.form.get("subject") or "").strip() | ||||||
|         body = request.form["body"] |         body_html = request.form.get("body") or "" | ||||||
|         result_message = process_send_update_email(subject, body) | 
 | ||||||
|         flash(result_message) |         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 redirect(url_for("send_update")) | ||||||
|  | 
 | ||||||
|     return render_template("send_update.html") |     return render_template("send_update.html") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.route("/login", methods=["GET", "POST"]) | @app.route("/login", methods=["GET", "POST"]) | ||||||
| def login(): | def login(): | ||||||
|     if request.method == "POST": |     if request.method == "POST": | ||||||
|         username = request.form.get("username") |         username = (request.form.get("username") or "").strip() | ||||||
|         password = request.form.get("password") |         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) |         admin = get_admin(username) | ||||||
|         if admin and check_password_hash(admin[1], password): |         if admin and check_password_hash(admin[1], password): | ||||||
|             session["username"] = username |             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") |             flash("Logged in successfully", "success") | ||||||
|             return redirect(url_for("index")) |             return redirect(url_for("index")) | ||||||
|         else: |         else: | ||||||
|             flash("Invalid username or password", "danger") |             flash("Invalid username or password", "danger") | ||||||
|             return redirect(url_for("login")) |             return redirect(url_for("login")) | ||||||
|  | 
 | ||||||
|     return render_template("login.html") |     return render_template("login.html") | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @app.route("/logout") | @app.route("/logout", methods=["GET"]) | ||||||
| def logout(): | def logout(): | ||||||
|     session.pop("username", None) |     session.pop("username", None) | ||||||
|     flash("Logged out successfully", "success") |     flash("Logged out successfully", "success") | ||||||
|  | @ -147,4 +236,9 @@ def logout(): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| if __name__ == "__main__": | 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) | ||||||
							
								
								
									
										179
									
								
								database.py
									
										
									
									
									
								
							
							
						
						
									
										179
									
								
								database.py
									
										
									
									
									
								
							|  | @ -1,54 +1,53 @@ | ||||||
| import os | import os | ||||||
| import logging |  | ||||||
| import psycopg2 | import psycopg2 | ||||||
| from psycopg2 import IntegrityError | from psycopg2 import IntegrityError, pool, OperationalError | ||||||
| from dotenv import load_dotenv | from dotenv import load_dotenv | ||||||
| from werkzeug.security import generate_password_hash | from werkzeug.security import generate_password_hash | ||||||
| 
 | 
 | ||||||
| load_dotenv() | load_dotenv() | ||||||
| 
 | 
 | ||||||
| # Logging setup | try: | ||||||
| logging.basicConfig( |     DB_MIN_CONN = int(os.getenv("DB_MIN_CONN", 1)) | ||||||
|     level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" |     DB_MAX_CONN = int(os.getenv("DB_MAX_CONN", 10)) | ||||||
| ) | 
 | ||||||
| logger = logging.getLogger(__name__) |     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(): | def get_connection(): | ||||||
|     """Return a new connection to the PostgreSQL database.""" |     """Get a connection from the connection pool.""" | ||||||
|     try: |     return conn_pool.getconn() | ||||||
|         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 |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def init_db(): | def init_db(): | ||||||
|     """Initialize the database tables.""" |     """Initialize database tables with connection pool.""" | ||||||
|     conn = None |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         conn = get_connection() | ||||||
|         cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
| 
 | 
 | ||||||
|         # Create subscribers table (if not exists) |  | ||||||
|         cursor.execute( |         cursor.execute( | ||||||
|             """ |             """ | ||||||
|             CREATE TABLE IF NOT EXISTS subscribers ( |             CREATE TABLE IF NOT EXISTS subscribers ( | ||||||
|                 id SERIAL PRIMARY KEY, |                 id SERIAL PRIMARY KEY, | ||||||
|                 email TEXT UNIQUE NOT NULL |                 email TEXT UNIQUE NOT NULL | ||||||
|             ) |             ) | ||||||
|         """ |             """ | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         # Create admin_users table (if not exists) |  | ||||||
|         cursor.execute( |         cursor.execute( | ||||||
|             """ |             """ | ||||||
|             CREATE TABLE IF NOT EXISTS admin_users ( |             CREATE TABLE IF NOT EXISTS admin_users ( | ||||||
|  | @ -56,94 +55,96 @@ def init_db(): | ||||||
|                 username TEXT UNIQUE NOT NULL, |                 username TEXT UNIQUE NOT NULL, | ||||||
|                 password TEXT NOT NULL |                 password TEXT NOT NULL | ||||||
|             ) |             ) | ||||||
|         """ |             """ | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         # Newsletter storage |  | ||||||
|         cursor.execute( |         cursor.execute( | ||||||
|             """ |             """ | ||||||
|         CREATE TABLE IF NOT EXISTS newsletters ( |             CREATE TABLE IF NOT EXISTS newsletters ( | ||||||
|             id SERIAL PRIMARY KEY, |                 id SERIAL PRIMARY KEY, | ||||||
|             subject TEXT NOT NULL, |                 subject TEXT NOT NULL, | ||||||
|             body TEXT NOT NULL, |                 body TEXT NOT NULL, | ||||||
|             sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP |                 sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP | ||||||
|         ) |             ) | ||||||
|         """ |             """ | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         conn.commit() |         conn.commit() | ||||||
|         logger.info("Database initialized successfully.") |     except Exception: | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Database initialization error: {e}") |  | ||||||
|         if conn: |         if conn: | ||||||
|             conn.rollback()  # Rollback if there's an error |             conn.rollback() | ||||||
| 
 |  | ||||||
|         raise |         raise | ||||||
|     finally: |     finally: | ||||||
|         if conn: |         if cursor: | ||||||
|             cursor.close() |             cursor.close() | ||||||
|             conn.close() |         if conn: | ||||||
|  |             conn_pool.putconn(conn) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_all_emails(): | def get_all_emails(): | ||||||
|     """Return a list of all subscriber emails.""" |     """Return a list of all subscriber emails.""" | ||||||
|  |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         conn = get_connection() | ||||||
|         cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
|         cursor.execute("SELECT email FROM subscribers") |         cursor.execute("SELECT email FROM subscribers") | ||||||
|         results = cursor.fetchall() |         results = cursor.fetchall() | ||||||
|         emails = [row[0] for row in results] |         return [row[0] for row in results] | ||||||
|         logger.debug(f"Retrieved emails: {emails}") |     except Exception: | ||||||
|         return emails |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error retrieving emails: {e}") |  | ||||||
|         return [] |         return [] | ||||||
|     finally: |     finally: | ||||||
|         if conn: |         if cursor: | ||||||
|             cursor.close() |             cursor.close() | ||||||
|             conn.close() |         if conn: | ||||||
|  |             conn_pool.putconn(conn) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def add_email(email): | def add_email(email): | ||||||
|     """Insert an email into the subscribers table.""" |     """Insert an email into the subscribers table.""" | ||||||
|     conn = None |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         conn = get_connection() | ||||||
|         cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
|         cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) |         cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) | ||||||
|         conn.commit() |         conn.commit() | ||||||
|         logger.info(f"Email {email} added successfully.") |  | ||||||
|         return True |         return True | ||||||
|     except IntegrityError: |     except IntegrityError: | ||||||
|         logger.warning(f"Attempted to add duplicate email: {email}") |         if conn: | ||||||
|  |             conn.rollback() | ||||||
|         return False |         return False | ||||||
|     except Exception as e: |     except Exception: | ||||||
|         logger.error(f"Error adding email {email}: {e}") |         if conn: | ||||||
|  |             conn.rollback() | ||||||
|         return False |         return False | ||||||
|     finally: |     finally: | ||||||
|         if conn: |         if cursor: | ||||||
|             cursor.close() |             cursor.close() | ||||||
|             conn.close() |         if conn: | ||||||
|  |             conn_pool.putconn(conn) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def remove_email(email): | def remove_email(email): | ||||||
|     """Remove an email from the subscribers table.""" |     """Remove an email from the subscribers table.""" | ||||||
|     conn = None |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         conn = get_connection() | ||||||
|         cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
|         cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) |         cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) | ||||||
|         rowcount = cursor.rowcount |         rowcount = cursor.rowcount | ||||||
|         conn.commit() |         conn.commit() | ||||||
|         logger.info(f"Email {email} removed successfully.") |  | ||||||
|         return rowcount > 0 |         return rowcount > 0 | ||||||
|     except Exception as e: |     except Exception: | ||||||
|         logger.error(f"Error removing email {email}: {e}") |         if conn: | ||||||
|  |             conn.rollback() | ||||||
|         return False |         return False | ||||||
|     finally: |     finally: | ||||||
|         if conn: |         if cursor: | ||||||
|             cursor.close() |             cursor.close() | ||||||
|             conn.close() |         if conn: | ||||||
|  |             conn_pool.putconn(conn) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_admin(username): | def get_admin(username): | ||||||
|  | @ -151,6 +152,7 @@ def get_admin(username): | ||||||
|     Returns a tuple (username, password_hash) if found, otherwise None. |     Returns a tuple (username, password_hash) if found, otherwise None. | ||||||
|     """ |     """ | ||||||
|     conn = None |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         conn = get_connection() | ||||||
|         cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
|  | @ -158,15 +160,14 @@ def get_admin(username): | ||||||
|             "SELECT username, password FROM admin_users WHERE username = %s", |             "SELECT username, password FROM admin_users WHERE username = %s", | ||||||
|             (username,), |             (username,), | ||||||
|         ) |         ) | ||||||
|         result = cursor.fetchone() |         return cursor.fetchone() | ||||||
|         return result  # (username, password_hash) |     except Exception: | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error retrieving admin: {e}") |  | ||||||
|         return None |         return None | ||||||
|     finally: |     finally: | ||||||
|         if conn: |         if cursor: | ||||||
|             cursor.close() |             cursor.close() | ||||||
|             conn.close() |         if conn: | ||||||
|  |             conn_pool.putconn(conn) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def create_default_admin(): | def create_default_admin(): | ||||||
|  | @ -174,30 +175,60 @@ def create_default_admin(): | ||||||
|     default_username = os.getenv("ADMIN_USERNAME", "admin") |     default_username = os.getenv("ADMIN_USERNAME", "admin") | ||||||
|     default_password = os.getenv("ADMIN_PASSWORD", "changeme") |     default_password = os.getenv("ADMIN_PASSWORD", "changeme") | ||||||
|     hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") |     hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") | ||||||
|  | 
 | ||||||
|     conn = None |     conn = None | ||||||
|  |     cursor = None | ||||||
|     try: |     try: | ||||||
|         conn = get_connection() |         conn = get_connection() | ||||||
|         cursor = conn.cursor() |         cursor = conn.cursor() | ||||||
| 
 | 
 | ||||||
|         # Check if the admin already exists |  | ||||||
|         cursor.execute( |         cursor.execute( | ||||||
|             "SELECT id FROM admin_users WHERE username = %s", (default_username,) |             "SELECT id FROM admin_users WHERE username = %s", (default_username,) | ||||||
|         ) |         ) | ||||||
|         if cursor.fetchone() is None: |         exists = cursor.fetchone() | ||||||
|  |         if exists is None: | ||||||
|             cursor.execute( |             cursor.execute( | ||||||
|                 "INSERT INTO admin_users (username, password) VALUES (%s, %s)", |                 "INSERT INTO admin_users (username, password) VALUES (%s, %s)", | ||||||
|                 (default_username, hashed_password), |                 (default_username, hashed_password), | ||||||
|             ) |             ) | ||||||
|             conn.commit() |             conn.commit() | ||||||
|             logger.info("Default admin created successfully") |     except Exception: | ||||||
|         else: |  | ||||||
|             logger.info("Default admin already exists") |  | ||||||
|     except Exception as e: |  | ||||||
|         logger.error(f"Error creating default admin: {e}") |  | ||||||
|         if conn: |         if conn: | ||||||
|             conn.rollback() |             conn.rollback() | ||||||
|     finally: |     finally: | ||||||
|         if conn: |         if cursor: | ||||||
|             cursor.close() |             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) | ||||||
|  | @ -3,3 +3,6 @@ flask | ||||||
| python-dotenv | python-dotenv | ||||||
| Werkzeug | Werkzeug | ||||||
| psycopg2-binary | psycopg2-binary | ||||||
|  | psycopg2-pool | ||||||
|  | python-decouple | ||||||
|  | markupsafe | ||||||
|  | @ -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 { | body { | ||||||
|     font-family: Arial, sans-serif; |   font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; | ||||||
|     padding: 20px; |   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; |  | ||||||
|   } |  | ||||||
|    |  | ||||||
|  | @ -1,44 +1,53 @@ | ||||||
| <!DOCTYPE html> | {% extends "base.html" %} | ||||||
| <html lang="en"> | {% block title %}Dashboard{% endblock %} | ||||||
| <head> | {% block content %} | ||||||
|     <meta charset="UTF-8"> |   <div class="page-header"> | ||||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> |     <div> | ||||||
|     <title>Admin Center - Subscribers</title> |       <h1 class="page-title">Dashboard</h1> | ||||||
|     <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> |       <p class="page-subtitle">Quick overview of your mailing activity</p> | ||||||
| </head> |     </div> | ||||||
| <body> |     <div class="page-actions"> | ||||||
|  |       <a href="{{ url_for('send_update') }}" class="button button-primary">Send Update</a> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
| 
 | 
 | ||||||
|     <h1>Subscribers</h1> |   <section class="widgets"> | ||||||
|     <p> |     <div class="widget-card"> | ||||||
|       <a href="{{ url_for('send_update') }}">Send Update Email</a>| |       <div class="widget-label">Total Subscribers</div> | ||||||
|       <a href="{{ url_for('logout') }}">Logout</a> |       <div class="widget-value">{{ counts.total_subscribers }}</div> | ||||||
|     </p> |     </div> | ||||||
|  |     <div class="widget-card"> | ||||||
|  |       <div class="widget-label">Newsletters Sent</div> | ||||||
|  |       <div class="widget-value">{{ counts.total_newsletters }}</div> | ||||||
|  |     </div> | ||||||
|  |     <div class="widget-card"> | ||||||
|  |       <div class="widget-label">Sent Today</div> | ||||||
|  |       <div class="widget-value">{{ counts.sent_today }}</div> | ||||||
|  |     </div> | ||||||
|  |   </section> | ||||||
| 
 | 
 | ||||||
|     {% with messages = get_flashed_messages(with_categories=true) %} |   {% if emails %} | ||||||
|       {% if messages %} |     <div class="card"> | ||||||
|         {% for category, message in messages %} |       <div class="table-wrap"> | ||||||
|           <div class="flash">{{ message }}</div> |         <table class="table"> | ||||||
|         {% endfor %} |           <thead> | ||||||
|       {% endif %} |             <tr> | ||||||
|     {% endwith %} |               <th>Email Address</th> | ||||||
| 
 |             </tr> | ||||||
|     {% if emails %} |           </thead> | ||||||
|         <table> |           <tbody> | ||||||
|             <thead> |             {% for email in emails %} | ||||||
|                 <tr> |               <tr> | ||||||
|                     <th>Email Address</th> |                 <td>{{ email }}</td> | ||||||
|                 </tr> |               </tr> | ||||||
|             </thead> |             {% endfor %} | ||||||
|             <tbody> |           </tbody> | ||||||
|                 {% for email in emails %} |  | ||||||
|                     <tr> |  | ||||||
|                         <td>{{ email }}</td> |  | ||||||
|                     </tr> |  | ||||||
|                 {% endfor %} |  | ||||||
|             </tbody> |  | ||||||
|         </table> |         </table> | ||||||
|     {% else %} |       </div> | ||||||
|         <p>No subscribers found.</p> |     </div> | ||||||
|     {% endif %} |   {% else %} | ||||||
| </body> |     <div class="card empty-state"> | ||||||
| </html> |       <p>No subscribers found.</p> | ||||||
|  |     </div> | ||||||
|  |   {% endif %} | ||||||
|  | {% endblock %} | ||||||
							
								
								
									
										58
									
								
								templates/base.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								templates/base.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,58 @@ | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  | <head> | ||||||
|  |   <meta charset="UTF-8" /> | ||||||
|  |   <meta | ||||||
|  |     name="viewport" | ||||||
|  |     content="width=device-width, initial-scale=1.0, viewport-fit=cover" | ||||||
|  |   /> | ||||||
|  |   <title>{% block title %}Admin{% endblock %}</title> | ||||||
|  |   <link | ||||||
|  |     rel="stylesheet" | ||||||
|  |     href="{{ url_for('static', filename='css/style.css') }}" | ||||||
|  |   /> | ||||||
|  |   <link | ||||||
|  |     rel="preconnect" | ||||||
|  |     href="https://fonts.googleapis.com" | ||||||
|  |     crossorigin | ||||||
|  |   /> | ||||||
|  |   <link | ||||||
|  |     href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" | ||||||
|  |     rel="stylesheet" | ||||||
|  |   /> | ||||||
|  | </head> | ||||||
|  | <body> | ||||||
|  |   <header class="navbar"> | ||||||
|  |     <div class="navbar-content"> | ||||||
|  |       <a class="brand" href="{{ url_for('index') }}">Admin Panel</a> | ||||||
|  |       <nav class="navbar-links"> | ||||||
|  |         {% if session.get('username') %} | ||||||
|  |         <a href="{{ url_for('index') }}">Dashboard</a> | ||||||
|  |         <a href="{{ url_for('send_update') }}">Send Update</a> | ||||||
|  |         <a href="{{ url_for('logout') }}" class="logout">Logout</a> | ||||||
|  |         {% endif %} | ||||||
|  |       </nav> | ||||||
|  |     </div> | ||||||
|  |   </header> | ||||||
|  | 
 | ||||||
|  |   <main class="container"> | ||||||
|  |     {% with messages = get_flashed_messages(with_categories=true) %} | ||||||
|  |       {% if messages %} | ||||||
|  |         <div class="flash-stack"> | ||||||
|  |           {% for category, message in messages %} | ||||||
|  |             <div class="flash flash-{{ category|lower }}">{{ message }}</div> | ||||||
|  |           {% endfor %} | ||||||
|  |         </div> | ||||||
|  |       {% endif %} | ||||||
|  |     {% endwith %} | ||||||
|  | 
 | ||||||
|  |     {% block content %}{% endblock %} | ||||||
|  |   </main> | ||||||
|  | 
 | ||||||
|  |   <footer class="footer"> | ||||||
|  |     <div class="footer-inner"> | ||||||
|  |       <span>© {{ 2025 }} Admin Panel</span> | ||||||
|  |     </div> | ||||||
|  |   </footer> | ||||||
|  | </body> | ||||||
|  | </html> | ||||||
|  | @ -1,29 +1,34 @@ | ||||||
| <!DOCTYPE html> | {% extends "base.html" %} | ||||||
| <html lang="en"> | {% block title %}Admin Login{% endblock %} | ||||||
|  | {% block content %} | ||||||
|  |   <section class="auth-wrapper"> | ||||||
|  |     <div class="card auth-card"> | ||||||
|  |       <h1 class="page-title">Welcome back</h1> | ||||||
|  |       <p class="page-subtitle">Sign in to manage your subscribers</p> | ||||||
| 
 | 
 | ||||||
| <head> |       <form action="{{ url_for('login') }}" method="POST" class="form"> | ||||||
|   <meta charset="UTF-8"> |         <div class="form-group"> | ||||||
|   <meta name="viewport" content="width=device-width, initial-scale=1.0"> |           <label for="username">Username</label> | ||||||
|   <title>Admin Login</title> |           <input | ||||||
|   <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> |             type="text" | ||||||
| </head> |             name="username" | ||||||
| 
 |             id="username" | ||||||
| <body> |             autocomplete="username" | ||||||
|   <h1>Admin Login</h1> |             required | ||||||
|   {% with messages = get_flashed_messages(with_categories=true) %} |           /> | ||||||
|   {% if messages %} |         </div> | ||||||
|   {% for category, message in messages %} |         <div class="form-group"> | ||||||
|   <div class="flash">{{ message }}</div> |           <label for="password">Password</label> | ||||||
|   {% endfor %} |           <input | ||||||
|   {% endif %} |             type="password" | ||||||
|   {% endwith %} |             name="password" | ||||||
|   <form action="{{ url_for('login') }}" method="POST"> |             id="password" | ||||||
|     <label for="username">Username:</label> |             autocomplete="current-password" | ||||||
|     <input type="text" name="username" required /> |             required | ||||||
|     <label for="password">Password:</label> |           /> | ||||||
|     <input type="password" name="password" required /> |         </div> | ||||||
|     <button type="submit">Login</button> |         <button type="submit" class="button button-primary">Login</button> | ||||||
|   </form> |       </form> | ||||||
| </body> |     </div> | ||||||
| 
 |   </section> | ||||||
| </html> | {% endblock %} | ||||||
|  | @ -1,37 +1,32 @@ | ||||||
| <!DOCTYPE html> | {% extends "base.html" %} | ||||||
| <html lang="en"> | {% block title %}Send Update{% endblock %} | ||||||
|  | {% block content %} | ||||||
|  |   <div class="page-header"> | ||||||
|  |     <div> | ||||||
|  |       <h1 class="page-title">Send Update</h1> | ||||||
|  |       <p class="page-subtitle">Send an email update to all subscribers</p> | ||||||
|  |     </div> | ||||||
|  |     <div class="page-actions"> | ||||||
|  |       <a href="{{ url_for('index') }}" class="button button-secondary">Dashboard</a> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
| 
 | 
 | ||||||
| <head> |   <div class="card"> | ||||||
|   <meta charset="UTF-8"> |     <form action="{{ url_for('send_update') }}" method="POST" class="form"> | ||||||
|   <meta name="viewport" content="width=device-width, initial-scale=1.0"> |       <div class="form-group"> | ||||||
|   <title>Admin Center - Send Update</title> |         <label for="subject">Subject</label> | ||||||
|   <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> |         <input type="text" name="subject" id="subject" required /> | ||||||
| </head> |       </div> | ||||||
| 
 | 
 | ||||||
| <body> |       <div class="form-group"> | ||||||
|   <h1>Send Update Email</h1> |         <label for="body">Body (HTML allowed)</label> | ||||||
|   <p> |         <textarea name="body" id="body" rows="12" required | ||||||
|     <a href="{{ url_for('index') }}">Back to Subscribers List</a> | |           placeholder="<h1>Title</h1><p>Your content...</p>"></textarea> | ||||||
|     <a href="{{ url_for('logout') }}">Logout</a> |       </div> | ||||||
|   </p> |  | ||||||
|   {% with messages = get_flashed_messages() %} |  | ||||||
|     {% if messages %} |  | ||||||
|       {% for message in messages %} |  | ||||||
|         <div class="flash">{{ message }}</div> |  | ||||||
|       {% endfor %} |  | ||||||
|     {% endif %} |  | ||||||
|   {% endwith %} |  | ||||||
| 
 | 
 | ||||||
|   <form action="{{ url_for('send_update') }}" method="POST"> |       <div class="form-actions"> | ||||||
|     <label for="subject">Subject:</label> |         <button type="submit" class="button button-primary">Send Update</button> | ||||||
|     <input type="text" name="subject" required> |       </div> | ||||||
| 
 |     </form> | ||||||
|     <label for="body">Body (HTML allowed):</label> |   </div> | ||||||
|     <textarea name="body" rows="10" required></textarea> | {% endblock %} | ||||||
| 
 |  | ||||||
|     <button type="submit">Send Update</button> |  | ||||||
|   </form> |  | ||||||
| 
 |  | ||||||
| </body> |  | ||||||
| 
 |  | ||||||
| </html> |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue