Compare commits
	
		
			No commits in common. "797266226be5f626583959b41771d087d3ed8a03" and "9d78f1fdb48013bb51c92d85d82350c3e8a85e72" have entirely different histories.
		
	
	
		
			797266226b
			...
			9d78f1fdb4
		
	
		
					 9 changed files with 299 additions and 757 deletions
				
			
		
							
								
								
									
										22
									
								
								Dockerfile
									
										
									
									
									
								
							
							
						
						
									
										22
									
								
								Dockerfile
									
										
									
									
									
								
							|  | @ -1,30 +1,18 @@ | ||||||
| # Use an official Python runtime as a base |  | ||||||
| FROM python:3.11-slim-buster | FROM python:3.11-slim-buster | ||||||
| 
 | 
 | ||||||
| # Set working directory | # Install build dependencies (build-essential provides gcc and other tools) | ||||||
| WORKDIR /app | RUN apt-get update && apt-get install -y build-essential | ||||||
| 
 | 
 | ||||||
| # Install system dependencies | WORKDIR /rideaware_landing | ||||||
| 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 . . | ||||||
| 
 | 
 | ||||||
| # Environment variables | ENV FLASK_APP=server.py | ||||||
| ENV FLASK_APP=app.py |  | ||||||
| ENV FLASK_ENV=production |  | ||||||
| ENV ENVIRONMENT=production |  | ||||||
| 
 | 
 | ||||||
| EXPOSE 5001 | EXPOSE 5001 | ||||||
| 
 | 
 | ||||||
| # Use Gunicorn as production server | CMD ["gunicorn", "--bind", "0.0.0.0:5001", "app:app"] | ||||||
| CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "4", "--timeout", "120", "app:app"] |  | ||||||
|  |  | ||||||
							
								
								
									
										198
									
								
								app.py
									
										
									
									
									
								
							
							
						
						
									
										198
									
								
								app.py
									
										
									
									
									
								
							|  | @ -1,9 +1,7 @@ | ||||||
| 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, | ||||||
|  | @ -13,222 +11,135 @@ 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 ( | from database import get_connection, init_db, get_all_emails, get_admin, create_default_admin | ||||||
|     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", "").strip().strip("/") | base_url = os.getenv("BASE_URL") | ||||||
| 
 | 
 | ||||||
|  | # 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) | SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) # Use SENDER_EMAIL | ||||||
| 
 | 
 | ||||||
|  | # 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) |     @wraps(f)  # Use wraps to preserve function metadata | ||||||
|     def decorated_function(*args, **kwargs): |     def decorated_function(*args, **kwargs): | ||||||
|         if "username" not in session: |         if "username" not in session: | ||||||
|             next_url = request.full_path if request.query_string else request.path |             return redirect(url_for("login")) | ||||||
|             return redirect(url_for("login", next=next_url)) |  | ||||||
|         return f(*args, **kwargs) |         return f(*args, **kwargs) | ||||||
| 
 | 
 | ||||||
|     return decorated_function |     return decorated_function | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_dashboard_counts(): | def send_update_email(subject, body, email): | ||||||
|     """Return dict of counts: total subscribers, total newsletters, sent today.""" |     """Sends email, returns True on success, False on failure.""" | ||||||
|     counts = {"total_subscribers": 0, "total_newsletters": 0, "sent_today": 0} |  | ||||||
|     try: |  | ||||||
|         conn = get_connection() |  | ||||||
|         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"<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: |     try: | ||||||
|         server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) |         server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) | ||||||
|             server.set_debuglevel(0) |         server.set_debuglevel(False)  # Keep debug level at False for production | ||||||
|         server.login(SMTP_USER, SMTP_PASSWORD) |         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 = MIMEText(custom_body, "html", "utf-8") | ||||||
|         msg["Subject"] = subject |         msg["Subject"] = subject | ||||||
|             msg["From"] = SENDER_EMAIL |         msg["From"] = SENDER_EMAIL  # Use sender email | ||||||
|         msg["To"] = email |         msg["To"] = email | ||||||
| 
 | 
 | ||||||
|             server.sendmail(SENDER_EMAIL, [email], msg.as_string()) |         server.sendmail(SENDER_EMAIL, email, msg.as_string())  # Use sender email | ||||||
|  | 
 | ||||||
|         server.quit() |         server.quit() | ||||||
|  |         logger.info(f"Update email sent to: {email}") | ||||||
|         return True |         return True | ||||||
|         except Exception: |     except Exception as e: | ||||||
|             retry_count += 1 |         logger.error(f"Failed to send email to {email}: {e}") | ||||||
|             if retry_count >= max_retries: |  | ||||||
|                 break |  | ||||||
|             import time |  | ||||||
| 
 |  | ||||||
|             time.sleep(1.0) |  | ||||||
| 
 |  | ||||||
|         return False |         return False | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def process_send_update_email(subject: str, body_html: str) -> str: | def process_send_update_email(subject, body): | ||||||
|     """Send update email to all subscribers and log newsletter content.""" |     """Helper function to send an update email to all subscribers.""" | ||||||
|     try: |  | ||||||
|     subscribers = get_all_emails() |     subscribers = get_all_emails() | ||||||
|     if not subscribers: |     if not subscribers: | ||||||
|         return "No subscribers found." |         return "No subscribers found." | ||||||
| 
 |  | ||||||
|         failures = [] |  | ||||||
|         for email in subscribers: |  | ||||||
|             if not send_update_email(subject, body_html, email): |  | ||||||
|                 failures.append(email) |  | ||||||
| 
 |  | ||||||
|     try: |     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() |         cursor = conn.cursor() | ||||||
|         cursor.execute( |         cursor.execute( | ||||||
|                 "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", |             "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", (subject, body) | ||||||
|                 (subject, body_html), |  | ||||||
|         ) |         ) | ||||||
|         conn.commit() |         conn.commit() | ||||||
|         cursor.close() |         cursor.close() | ||||||
|         conn.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("/", methods=["GET"]) | @app.route("/") | ||||||
| @login_required | @login_required | ||||||
| def index(): | def index(): | ||||||
|     """Dashboard: list subscriber emails and show widgets.""" |     """Displays all subscriber emails""" | ||||||
|     emails = [] |  | ||||||
|     try: |  | ||||||
|     emails = get_all_emails() |     emails = get_all_emails() | ||||||
|     except Exception: |     return render_template("admin_index.html", emails=emails) | ||||||
|         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.get("subject") or "").strip() |         subject = request.form["subject"] | ||||||
|         body_html = request.form.get("body") or "" |         body = request.form["body"] | ||||||
| 
 |         result_message = process_send_update_email(subject, body) | ||||||
|         if not subject or not body_html: |         flash(result_message) | ||||||
|             flash("Subject and body are required", "danger") |  | ||||||
|         return redirect(url_for("send_update")) |         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") |     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") or "").strip() |         username = request.form.get("username") | ||||||
|         password = (request.form.get("password") or "").strip() |         password = request.form.get("password") | ||||||
| 
 |  | ||||||
|         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", methods=["GET"]) | @app.route("/logout") | ||||||
| def logout(): | def logout(): | ||||||
|     session.pop("username", None) |     session.pop("username", None) | ||||||
|     flash("Logged out successfully", "success") |     flash("Logged out successfully", "success") | ||||||
|  | @ -236,9 +147,4 @@ def logout(): | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| if __name__ == "__main__": | if __name__ == "__main__": | ||||||
|     is_prod = os.getenv("ENVIRONMENT", "development").lower() == "production" |     app.run(port=5001, debug=True) | ||||||
|     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) |  | ||||||
|  |  | ||||||
							
								
								
									
										149
									
								
								database.py
									
										
									
									
									
								
							
							
						
						
									
										149
									
								
								database.py
									
										
									
									
									
								
							|  | @ -1,18 +1,23 @@ | ||||||
| import os | import os | ||||||
|  | import logging | ||||||
| import psycopg2 | import psycopg2 | ||||||
| from psycopg2 import IntegrityError, pool, OperationalError | from psycopg2 import IntegrityError | ||||||
| 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() | ||||||
| 
 | 
 | ||||||
| try: | # Logging setup | ||||||
|     DB_MIN_CONN = int(os.getenv("DB_MIN_CONN", 1)) | logging.basicConfig( | ||||||
|     DB_MAX_CONN = int(os.getenv("DB_MAX_CONN", 10)) |     level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" | ||||||
|  | ) | ||||||
|  | logger = logging.getLogger(__name__) | ||||||
| 
 | 
 | ||||||
|     conn_pool = pool.ThreadedConnectionPool( | 
 | ||||||
|         minconn=DB_MIN_CONN, | def get_connection(): | ||||||
|         maxconn=DB_MAX_CONN, |     """Return a new connection to the PostgreSQL database.""" | ||||||
|  |     try: | ||||||
|  |         conn = psycopg2.connect( | ||||||
|             host=os.getenv("PG_HOST"), |             host=os.getenv("PG_HOST"), | ||||||
|             port=os.getenv("PG_PORT"), |             port=os.getenv("PG_PORT"), | ||||||
|             dbname=os.getenv("PG_DATABASE"), |             dbname=os.getenv("PG_DATABASE"), | ||||||
|  | @ -20,25 +25,20 @@ try: | ||||||
|             password=os.getenv("PG_PASSWORD"), |             password=os.getenv("PG_PASSWORD"), | ||||||
|             connect_timeout=10, |             connect_timeout=10, | ||||||
|         ) |         ) | ||||||
| except OperationalError: |         return conn | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Database connection error: {e}") | ||||||
|         raise |         raise | ||||||
| except Exception: |  | ||||||
|     raise |  | ||||||
| 
 |  | ||||||
| 
 |  | ||||||
| def get_connection(): |  | ||||||
|     """Get a connection from the connection pool.""" |  | ||||||
|     return conn_pool.getconn() |  | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def init_db(): | def init_db(): | ||||||
|     """Initialize database tables with connection pool.""" |     """Initialize the database tables.""" | ||||||
|     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 ( | ||||||
|  | @ -48,6 +48,7 @@ def init_db(): | ||||||
|         """ |         """ | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |         # 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 ( | ||||||
|  | @ -58,6 +59,7 @@ def init_db(): | ||||||
|         """ |         """ | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|  |         # Newsletter storage | ||||||
|         cursor.execute( |         cursor.execute( | ||||||
|             """ |             """ | ||||||
|         CREATE TABLE IF NOT EXISTS newsletters ( |         CREATE TABLE IF NOT EXISTS newsletters ( | ||||||
|  | @ -70,81 +72,78 @@ def init_db(): | ||||||
|         ) |         ) | ||||||
| 
 | 
 | ||||||
|         conn.commit() |         conn.commit() | ||||||
|     except Exception: |         logger.info("Database initialized successfully.") | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Database initialization error: {e}") | ||||||
|         if conn: |         if conn: | ||||||
|             conn.rollback() |             conn.rollback()  # Rollback if there's an error | ||||||
|  | 
 | ||||||
|         raise |         raise | ||||||
|     finally: |     finally: | ||||||
|         if cursor: |  | ||||||
|             cursor.close() |  | ||||||
|         if conn: |         if conn: | ||||||
|             conn_pool.putconn(conn) |             cursor.close() | ||||||
|  |             conn.close() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 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() | ||||||
|         return [row[0] for row in results] |         emails = [row[0] for row in results] | ||||||
|     except Exception: |         logger.debug(f"Retrieved emails: {emails}") | ||||||
|  |         return emails | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Error retrieving emails: {e}") | ||||||
|         return [] |         return [] | ||||||
|     finally: |     finally: | ||||||
|         if cursor: |  | ||||||
|             cursor.close() |  | ||||||
|         if conn: |         if conn: | ||||||
|             conn_pool.putconn(conn) |             cursor.close() | ||||||
|  |             conn.close() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 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: | ||||||
|         if conn: |         logger.warning(f"Attempted to add duplicate email: {email}") | ||||||
|             conn.rollback() |  | ||||||
|         return False |         return False | ||||||
|     except Exception: |     except Exception as e: | ||||||
|         if conn: |         logger.error(f"Error adding email {email}: {e}") | ||||||
|             conn.rollback() |  | ||||||
|         return False |         return False | ||||||
|     finally: |     finally: | ||||||
|         if cursor: |  | ||||||
|             cursor.close() |  | ||||||
|         if conn: |         if conn: | ||||||
|             conn_pool.putconn(conn) |             cursor.close() | ||||||
|  |             conn.close() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| 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: |     except Exception as e: | ||||||
|         if conn: |         logger.error(f"Error removing email {email}: {e}") | ||||||
|             conn.rollback() |  | ||||||
|         return False |         return False | ||||||
|     finally: |     finally: | ||||||
|         if cursor: |  | ||||||
|             cursor.close() |  | ||||||
|         if conn: |         if conn: | ||||||
|             conn_pool.putconn(conn) |             cursor.close() | ||||||
|  |             conn.close() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def get_admin(username): | def get_admin(username): | ||||||
|  | @ -152,7 +151,6 @@ 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() | ||||||
|  | @ -160,14 +158,15 @@ def get_admin(username): | ||||||
|             "SELECT username, password FROM admin_users WHERE username = %s", |             "SELECT username, password FROM admin_users WHERE username = %s", | ||||||
|             (username,), |             (username,), | ||||||
|         ) |         ) | ||||||
|         return cursor.fetchone() |         result = cursor.fetchone() | ||||||
|     except Exception: |         return result  # (username, password_hash) | ||||||
|  |     except Exception as e: | ||||||
|  |         logger.error(f"Error retrieving admin: {e}") | ||||||
|         return None |         return None | ||||||
|     finally: |     finally: | ||||||
|         if cursor: |  | ||||||
|             cursor.close() |  | ||||||
|         if conn: |         if conn: | ||||||
|             conn_pool.putconn(conn) |             cursor.close() | ||||||
|  |             conn.close() | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| def create_default_admin(): | def create_default_admin(): | ||||||
|  | @ -175,60 +174,30 @@ 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,) | ||||||
|         ) |         ) | ||||||
|         exists = cursor.fetchone() |         if cursor.fetchone() is None: | ||||||
|         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() | ||||||
|     except Exception: |             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}") | ||||||
|         if conn: |         if conn: | ||||||
|             conn.rollback() |             conn.rollback() | ||||||
|     finally: |     finally: | ||||||
|         if cursor: |  | ||||||
|             cursor.close() |  | ||||||
|         if conn: |         if conn: | ||||||
|             conn_pool.putconn(conn) |             cursor.close() | ||||||
|  |             conn.close() | ||||||
| 
 | 
 | ||||||
| 
 |  | ||||||
| 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,6 +3,3 @@ flask | ||||||
| python-dotenv | python-dotenv | ||||||
| Werkzeug | Werkzeug | ||||||
| psycopg2-binary | psycopg2-binary | ||||||
| psycopg2-pool |  | ||||||
| python-decouple |  | ||||||
| markupsafe |  | ||||||
|  | @ -1,306 +1,55 @@ | ||||||
| :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: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; |     font-family: 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; |     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,35 +1,29 @@ | ||||||
| {% extends "base.html" %} | <!DOCTYPE html> | ||||||
| {% block title %}Dashboard{% endblock %} | <html lang="en"> | ||||||
| {% block content %} | <head> | ||||||
|   <div class="page-header"> |     <meta charset="UTF-8"> | ||||||
|     <div> |     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|       <h1 class="page-title">Dashboard</h1> |     <title>Admin Center - Subscribers</title> | ||||||
|       <p class="page-subtitle">Quick overview of your mailing activity</p> |     <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> | ||||||
|     </div> | </head> | ||||||
|     <div class="page-actions"> | <body> | ||||||
|       <a href="{{ url_for('send_update') }}" class="button button-primary">Send Update</a> |  | ||||||
|     </div> |  | ||||||
|   </div> |  | ||||||
| 
 | 
 | ||||||
|   <section class="widgets"> |     <h1>Subscribers</h1> | ||||||
|     <div class="widget-card"> |     <p> | ||||||
|       <div class="widget-label">Total Subscribers</div> |       <a href="{{ url_for('send_update') }}">Send Update Email</a>| | ||||||
|       <div class="widget-value">{{ counts.total_subscribers }}</div> |       <a href="{{ url_for('logout') }}">Logout</a> | ||||||
|     </div> |     </p> | ||||||
|     <div class="widget-card"> | 
 | ||||||
|       <div class="widget-label">Newsletters Sent</div> |     {% with messages = get_flashed_messages(with_categories=true) %} | ||||||
|       <div class="widget-value">{{ counts.total_newsletters }}</div> |       {% if messages %} | ||||||
|     </div> |         {% for category, message in messages %} | ||||||
|     <div class="widget-card"> |           <div class="flash">{{ message }}</div> | ||||||
|       <div class="widget-label">Sent Today</div> |         {% endfor %} | ||||||
|       <div class="widget-value">{{ counts.sent_today }}</div> |       {% endif %} | ||||||
|     </div> |     {% endwith %} | ||||||
|   </section> |  | ||||||
| 
 | 
 | ||||||
|     {% if emails %} |     {% if emails %} | ||||||
|     <div class="card"> |         <table> | ||||||
|       <div class="table-wrap"> |  | ||||||
|         <table class="table"> |  | ||||||
|             <thead> |             <thead> | ||||||
|                 <tr> |                 <tr> | ||||||
|                     <th>Email Address</th> |                     <th>Email Address</th> | ||||||
|  | @ -43,11 +37,8 @@ | ||||||
|                 {% endfor %} |                 {% endfor %} | ||||||
|             </tbody> |             </tbody> | ||||||
|         </table> |         </table> | ||||||
|       </div> |  | ||||||
|     </div> |  | ||||||
|     {% else %} |     {% else %} | ||||||
|     <div class="card empty-state"> |  | ||||||
|         <p>No subscribers found.</p> |         <p>No subscribers found.</p> | ||||||
|     </div> |  | ||||||
|     {% endif %} |     {% endif %} | ||||||
| {% endblock %} | </body> | ||||||
|  | </html> | ||||||
|  |  | ||||||
|  | @ -1,58 +0,0 @@ | ||||||
| <!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,34 +1,29 @@ | ||||||
| {% extends "base.html" %} | <!DOCTYPE html> | ||||||
| {% block title %}Admin Login{% endblock %} | <html lang="en"> | ||||||
| {% 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> |  | ||||||
| 
 | 
 | ||||||
|       <form action="{{ url_for('login') }}" method="POST" class="form"> | <head> | ||||||
|         <div class="form-group"> |   <meta charset="UTF-8"> | ||||||
|           <label for="username">Username</label> |   <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||||
|           <input |   <title>Admin Login</title> | ||||||
|             type="text" |   <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> | ||||||
|             name="username" | </head> | ||||||
|             id="username" | 
 | ||||||
|             autocomplete="username" | <body> | ||||||
|             required |   <h1>Admin Login</h1> | ||||||
|           /> |   {% with messages = get_flashed_messages(with_categories=true) %} | ||||||
|         </div> |   {% if messages %} | ||||||
|         <div class="form-group"> |   {% for category, message in messages %} | ||||||
|           <label for="password">Password</label> |   <div class="flash">{{ message }}</div> | ||||||
|           <input |   {% endfor %} | ||||||
|             type="password" |   {% endif %} | ||||||
|             name="password" |   {% endwith %} | ||||||
|             id="password" |   <form action="{{ url_for('login') }}" method="POST"> | ||||||
|             autocomplete="current-password" |     <label for="username">Username:</label> | ||||||
|             required |     <input type="text" name="username" required /> | ||||||
|           /> |     <label for="password">Password:</label> | ||||||
|         </div> |     <input type="password" name="password" required /> | ||||||
|         <button type="submit" class="button button-primary">Login</button> |     <button type="submit">Login</button> | ||||||
|   </form> |   </form> | ||||||
|     </div> | </body> | ||||||
|   </section> | 
 | ||||||
| {% endblock %} | </html> | ||||||
|  |  | ||||||
|  | @ -1,32 +1,37 @@ | ||||||
| {% extends "base.html" %} | <!DOCTYPE html> | ||||||
| {% block title %}Send Update{% endblock %} | <html lang="en"> | ||||||
| {% 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> |  | ||||||
| 
 | 
 | ||||||
|   <div class="card"> | <head> | ||||||
|     <form action="{{ url_for('send_update') }}" 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="subject">Subject</label> |   <title>Admin Center - Send Update</title> | ||||||
|         <input type="text" name="subject" id="subject" required /> |   <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> | ||||||
|       </div> | </head> | ||||||
| 
 | 
 | ||||||
|       <div class="form-group"> | <body> | ||||||
|         <label for="body">Body (HTML allowed)</label> |   <h1>Send Update Email</h1> | ||||||
|         <textarea name="body" id="body" rows="12" required |   <p> | ||||||
|           placeholder="<h1>Title</h1><p>Your content...</p>"></textarea> |     <a href="{{ url_for('index') }}">Back to Subscribers List</a> | | ||||||
|       </div> |     <a href="{{ url_for('logout') }}">Logout</a> | ||||||
|  |   </p> | ||||||
|  |   {% with messages = get_flashed_messages() %} | ||||||
|  |     {% if messages %} | ||||||
|  |       {% for message in messages %} | ||||||
|  |         <div class="flash">{{ message }}</div> | ||||||
|  |       {% endfor %} | ||||||
|  |     {% endif %} | ||||||
|  |   {% endwith %} | ||||||
| 
 | 
 | ||||||
|       <div class="form-actions"> |   <form action="{{ url_for('send_update') }}" method="POST"> | ||||||
|         <button type="submit" class="button button-primary">Send Update</button> |     <label for="subject">Subject:</label> | ||||||
|       </div> |     <input type="text" name="subject" required> | ||||||
|  | 
 | ||||||
|  |     <label for="body">Body (HTML allowed):</label> | ||||||
|  |     <textarea name="body" rows="10" required></textarea> | ||||||
|  | 
 | ||||||
|  |     <button type="submit">Send Update</button> | ||||||
|   </form> |   </form> | ||||||
|   </div> | 
 | ||||||
| {% endblock %} | </body> | ||||||
|  | 
 | ||||||
|  | </html> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue