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) | ||||
| RUN apt-get update && apt-get install -y build-essential | ||||
| # Set working directory | ||||
| WORKDIR /app | ||||
| 
 | ||||
| WORKDIR /rideaware_landing | ||||
| # Install system dependencies | ||||
| RUN apt-get update && apt-get install -y \ | ||||
|     build-essential \ | ||||
|     libpq-dev \ | ||||
|     && rm -rf /var/lib/apt/lists/* | ||||
| 
 | ||||
| # Copy requirements first to leverage Docker cache | ||||
| COPY requirements.txt . | ||||
| 
 | ||||
| # Install Python dependencies | ||||
| RUN pip install --no-cache-dir -r requirements.txt | ||||
| 
 | ||||
| # Copy application code | ||||
| COPY . . | ||||
| 
 | ||||
| ENV FLASK_APP=server.py | ||||
| # Environment variables | ||||
| ENV FLASK_APP=app.py | ||||
| ENV FLASK_ENV=production | ||||
| ENV ENVIRONMENT=production | ||||
| 
 | ||||
| EXPOSE 5001 | ||||
| 
 | ||||
| CMD ["gunicorn", "--bind", "0.0.0.0:5001", "app:app"] | ||||
| # Use Gunicorn as production server | ||||
| CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "4", "--timeout", "120", "app:app"] | ||||
							
								
								
									
										198
									
								
								app.py
									
										
									
									
									
								
							
							
						
						
									
										198
									
								
								app.py
									
										
									
									
									
								
							|  | @ -1,7 +1,9 @@ | |||
| import os | ||||
| import logging | ||||
| import smtplib | ||||
| from email.mime.text import MIMEText | ||||
| from functools import wraps | ||||
| from urllib.parse import urlparse, urljoin | ||||
| 
 | ||||
| from flask import ( | ||||
|     Flask, | ||||
|     render_template, | ||||
|  | @ -11,135 +13,222 @@ from flask import ( | |||
|     flash, | ||||
|     session, | ||||
| ) | ||||
| from markupsafe import escape | ||||
| from dotenv import load_dotenv | ||||
| from werkzeug.security import check_password_hash | ||||
| from functools import wraps  # Import wraps | ||||
| from database import get_connection, init_db, get_all_emails, get_admin, create_default_admin | ||||
| 
 | ||||
| from database import ( | ||||
|     get_connection, | ||||
|     init_db, | ||||
|     get_all_emails, | ||||
|     get_admin, | ||||
|     create_default_admin, | ||||
| ) | ||||
| 
 | ||||
| load_dotenv() | ||||
| 
 | ||||
| app = Flask(__name__) | ||||
| app.secret_key = os.getenv("SECRET_KEY") | ||||
| base_url = os.getenv("BASE_URL") | ||||
| base_url = os.getenv("BASE_URL", "").strip().strip("/") | ||||
| 
 | ||||
| # SMTP settings (for sending update emails) | ||||
| SMTP_SERVER = os.getenv("SMTP_SERVER") | ||||
| SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) | ||||
| SMTP_USER = os.getenv("SMTP_USER") | ||||
| SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") | ||||
| SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) # Use SENDER_EMAIL | ||||
| SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) | ||||
| 
 | ||||
| # Logging setup | ||||
| logging.basicConfig( | ||||
|     level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" | ||||
| ) | ||||
| logger = logging.getLogger(__name__) | ||||
| 
 | ||||
| # Initialize the database and create default admin user if necessary. | ||||
| init_db() | ||||
| create_default_admin() | ||||
| 
 | ||||
| # Decorator for requiring login | ||||
| 
 | ||||
| def login_required(f): | ||||
|     @wraps(f)  # Use wraps to preserve function metadata | ||||
|     @wraps(f) | ||||
|     def decorated_function(*args, **kwargs): | ||||
|         if "username" not in session: | ||||
|             return redirect(url_for("login")) | ||||
|             next_url = request.full_path if request.query_string else request.path | ||||
|             return redirect(url_for("login", next=next_url)) | ||||
|         return f(*args, **kwargs) | ||||
| 
 | ||||
|     return decorated_function | ||||
| 
 | ||||
| 
 | ||||
| def send_update_email(subject, body, email): | ||||
|     """Sends email, returns True on success, False on failure.""" | ||||
| def get_dashboard_counts(): | ||||
|     """Return dict of counts: total subscribers, total newsletters, sent today.""" | ||||
|     counts = {"total_subscribers": 0, "total_newsletters": 0, "sent_today": 0} | ||||
|     try: | ||||
|         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: | ||||
|             server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) | ||||
|         server.set_debuglevel(False)  # Keep debug level at False for production | ||||
|             server.set_debuglevel(0) | ||||
|             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["From"] = SENDER_EMAIL | ||||
|             msg["To"] = email | ||||
| 
 | ||||
|         server.sendmail(SENDER_EMAIL, email, msg.as_string())  # Use sender email | ||||
| 
 | ||||
|             server.sendmail(SENDER_EMAIL, [email], msg.as_string()) | ||||
|             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}") | ||||
|         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, body): | ||||
|     """Helper function to send an update email to all subscribers.""" | ||||
| 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." | ||||
|     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 | ||||
|         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) | ||||
|                 "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", | ||||
|                 (subject, body_html), | ||||
|             ) | ||||
|             conn.commit() | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         except Exception: | ||||
|             pass | ||||
| 
 | ||||
|         if failures: | ||||
|             return f"Sent with failures: {len(failures)} recipients failed." | ||||
|         return "Email has been sent to all subscribers." | ||||
|     except Exception as e: | ||||
|         logger.exception("Error processing sending updates") | ||||
|         return f"Failed to send email: {e}" | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/") | ||||
| @app.route("/", methods=["GET"]) | ||||
| @login_required | ||||
| def index(): | ||||
|     """Displays all subscriber emails""" | ||||
|     """Dashboard: list subscriber emails and show widgets.""" | ||||
|     emails = [] | ||||
|     try: | ||||
|         emails = get_all_emails() | ||||
|     return render_template("admin_index.html", emails=emails) | ||||
|     except Exception: | ||||
|         flash("Could not load subscribers right now.", "danger") | ||||
| 
 | ||||
|     counts = get_dashboard_counts() | ||||
|     return render_template("admin_index.html", emails=emails, counts=counts) | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/send_update", methods=["GET", "POST"]) | ||||
| @login_required | ||||
| def send_update(): | ||||
|     """Display a form to send an update email; process submission on POST.""" | ||||
|     if request.method == "POST": | ||||
|         subject = request.form["subject"] | ||||
|         body = request.form["body"] | ||||
|         result_message = process_send_update_email(subject, body) | ||||
|         flash(result_message) | ||||
|         subject = (request.form.get("subject") or "").strip() | ||||
|         body_html = request.form.get("body") or "" | ||||
| 
 | ||||
|         if not subject or not body_html: | ||||
|             flash("Subject and body are required", "danger") | ||||
|             return redirect(url_for("send_update")) | ||||
| 
 | ||||
|         result_message = process_send_update_email(subject, body_html) | ||||
|         flash(escape(result_message)) | ||||
|         return redirect(url_for("send_update")) | ||||
| 
 | ||||
|     return render_template("send_update.html") | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/login", methods=["GET", "POST"]) | ||||
| def login(): | ||||
|     if request.method == "POST": | ||||
|         username = request.form.get("username") | ||||
|         password = request.form.get("password") | ||||
|         username = (request.form.get("username") or "").strip() | ||||
|         password = (request.form.get("password") or "").strip() | ||||
| 
 | ||||
|         if not username or not password: | ||||
|             flash("Username and password are required", "danger") | ||||
|             return redirect(url_for("login")) | ||||
| 
 | ||||
|         admin = get_admin(username) | ||||
|         if admin and check_password_hash(admin[1], password): | ||||
|             session["username"] = username | ||||
|             session.permanent = True | ||||
|             app.config["SESSION_COOKIE_HTTPONLY"] = True | ||||
|             app.config["SESSION_COOKIE_SECURE"] = True | ||||
|             app.config["SESSION_COOKIE_SAMESITE"] = "Lax" | ||||
| 
 | ||||
|             next_url = request.args.get("next") | ||||
|             if next_url and is_safe_url(next_url): | ||||
|                 flash("Logged in successfully", "success") | ||||
|                 return redirect(next_url) | ||||
| 
 | ||||
|             flash("Logged in successfully", "success") | ||||
|             return redirect(url_for("index")) | ||||
|         else: | ||||
|             flash("Invalid username or password", "danger") | ||||
|             return redirect(url_for("login")) | ||||
| 
 | ||||
|     return render_template("login.html") | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/logout") | ||||
| @app.route("/logout", methods=["GET"]) | ||||
| def logout(): | ||||
|     session.pop("username", None) | ||||
|     flash("Logged out successfully", "success") | ||||
|  | @ -147,4 +236,9 @@ def logout(): | |||
| 
 | ||||
| 
 | ||||
| if __name__ == "__main__": | ||||
|     app.run(port=5001, debug=True) | ||||
|     is_prod = os.getenv("ENVIRONMENT", "development").lower() == "production" | ||||
|     app.config["PREFERRED_URL_SCHEME"] = "https" if is_prod else "http" | ||||
|     if is_prod: | ||||
|         app.run(host="0.0.0.0", port=5001, debug=False, use_reloader=False) | ||||
|     else: | ||||
|         app.run(host="0.0.0.0", port=5001, debug=True, use_reloader=True) | ||||
							
								
								
									
										149
									
								
								database.py
									
										
									
									
									
								
							
							
						
						
									
										149
									
								
								database.py
									
										
									
									
									
								
							|  | @ -1,23 +1,18 @@ | |||
| import os | ||||
| import logging | ||||
| import psycopg2 | ||||
| from psycopg2 import IntegrityError | ||||
| from psycopg2 import IntegrityError, pool, OperationalError | ||||
| from dotenv import load_dotenv | ||||
| from werkzeug.security import generate_password_hash | ||||
| 
 | ||||
| load_dotenv() | ||||
| 
 | ||||
| # Logging setup | ||||
| logging.basicConfig( | ||||
|     level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" | ||||
| ) | ||||
| logger = logging.getLogger(__name__) | ||||
| 
 | ||||
| 
 | ||||
| def get_connection(): | ||||
|     """Return a new connection to the PostgreSQL database.""" | ||||
| try: | ||||
|         conn = psycopg2.connect( | ||||
|     DB_MIN_CONN = int(os.getenv("DB_MIN_CONN", 1)) | ||||
|     DB_MAX_CONN = int(os.getenv("DB_MAX_CONN", 10)) | ||||
| 
 | ||||
|     conn_pool = pool.ThreadedConnectionPool( | ||||
|         minconn=DB_MIN_CONN, | ||||
|         maxconn=DB_MAX_CONN, | ||||
|         host=os.getenv("PG_HOST"), | ||||
|         port=os.getenv("PG_PORT"), | ||||
|         dbname=os.getenv("PG_DATABASE"), | ||||
|  | @ -25,20 +20,25 @@ def get_connection(): | |||
|         password=os.getenv("PG_PASSWORD"), | ||||
|         connect_timeout=10, | ||||
|     ) | ||||
|         return conn | ||||
|     except Exception as e: | ||||
|         logger.error(f"Database connection error: {e}") | ||||
| except OperationalError: | ||||
|     raise | ||||
| except Exception: | ||||
|     raise | ||||
| 
 | ||||
| 
 | ||||
| def get_connection(): | ||||
|     """Get a connection from the connection pool.""" | ||||
|     return conn_pool.getconn() | ||||
| 
 | ||||
| 
 | ||||
| def init_db(): | ||||
|     """Initialize the database tables.""" | ||||
|     """Initialize database tables with connection pool.""" | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
| 
 | ||||
|         # Create subscribers table (if not exists) | ||||
|         cursor.execute( | ||||
|             """ | ||||
|             CREATE TABLE IF NOT EXISTS subscribers ( | ||||
|  | @ -48,7 +48,6 @@ def init_db(): | |||
|             """ | ||||
|         ) | ||||
| 
 | ||||
|         # Create admin_users table (if not exists) | ||||
|         cursor.execute( | ||||
|             """ | ||||
|             CREATE TABLE IF NOT EXISTS admin_users ( | ||||
|  | @ -59,7 +58,6 @@ def init_db(): | |||
|             """ | ||||
|         ) | ||||
| 
 | ||||
|         # Newsletter storage | ||||
|         cursor.execute( | ||||
|             """ | ||||
|             CREATE TABLE IF NOT EXISTS newsletters ( | ||||
|  | @ -72,78 +70,81 @@ def init_db(): | |||
|         ) | ||||
| 
 | ||||
|         conn.commit() | ||||
|         logger.info("Database initialized successfully.") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Database initialization error: {e}") | ||||
|     except Exception: | ||||
|         if conn: | ||||
|             conn.rollback()  # Rollback if there's an error | ||||
| 
 | ||||
|             conn.rollback() | ||||
|         raise | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def get_all_emails(): | ||||
|     """Return a list of all subscriber emails.""" | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute("SELECT email FROM subscribers") | ||||
|         results = cursor.fetchall() | ||||
|         emails = [row[0] for row in results] | ||||
|         logger.debug(f"Retrieved emails: {emails}") | ||||
|         return emails | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error retrieving emails: {e}") | ||||
|         return [row[0] for row in results] | ||||
|     except Exception: | ||||
|         return [] | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def add_email(email): | ||||
|     """Insert an email into the subscribers table.""" | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) | ||||
|         conn.commit() | ||||
|         logger.info(f"Email {email} added successfully.") | ||||
|         return True | ||||
|     except IntegrityError: | ||||
|         logger.warning(f"Attempted to add duplicate email: {email}") | ||||
|         if conn: | ||||
|             conn.rollback() | ||||
|         return False | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error adding email {email}: {e}") | ||||
|     except Exception: | ||||
|         if conn: | ||||
|             conn.rollback() | ||||
|         return False | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def remove_email(email): | ||||
|     """Remove an email from the subscribers table.""" | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) | ||||
|         rowcount = cursor.rowcount | ||||
|         conn.commit() | ||||
|         logger.info(f"Email {email} removed successfully.") | ||||
|         return rowcount > 0 | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error removing email {email}: {e}") | ||||
|     except Exception: | ||||
|         if conn: | ||||
|             conn.rollback() | ||||
|         return False | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def get_admin(username): | ||||
|  | @ -151,6 +152,7 @@ def get_admin(username): | |||
|     Returns a tuple (username, password_hash) if found, otherwise None. | ||||
|     """ | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|  | @ -158,15 +160,14 @@ def get_admin(username): | |||
|             "SELECT username, password FROM admin_users WHERE username = %s", | ||||
|             (username,), | ||||
|         ) | ||||
|         result = cursor.fetchone() | ||||
|         return result  # (username, password_hash) | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error retrieving admin: {e}") | ||||
|         return cursor.fetchone() | ||||
|     except Exception: | ||||
|         return None | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def create_default_admin(): | ||||
|  | @ -174,30 +175,60 @@ def create_default_admin(): | |||
|     default_username = os.getenv("ADMIN_USERNAME", "admin") | ||||
|     default_password = os.getenv("ADMIN_PASSWORD", "changeme") | ||||
|     hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") | ||||
| 
 | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
| 
 | ||||
|         # Check if the admin already exists | ||||
|         cursor.execute( | ||||
|             "SELECT id FROM admin_users WHERE username = %s", (default_username,) | ||||
|         ) | ||||
|         if cursor.fetchone() is None: | ||||
|         exists = cursor.fetchone() | ||||
|         if exists is None: | ||||
|             cursor.execute( | ||||
|                 "INSERT INTO admin_users (username, password) VALUES (%s, %s)", | ||||
|                 (default_username, hashed_password), | ||||
|             ) | ||||
|             conn.commit() | ||||
|             logger.info("Default admin created successfully") | ||||
|         else: | ||||
|             logger.info("Default admin already exists") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error creating default admin: {e}") | ||||
|     except Exception: | ||||
|         if conn: | ||||
|             conn.rollback() | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def close_pool(): | ||||
|     """Close the database connection pool.""" | ||||
|     try: | ||||
|         conn_pool.closeall() | ||||
|     except Exception: | ||||
|         pass | ||||
| 
 | ||||
| 
 | ||||
| class DatabaseContext: | ||||
|     """Optional context manager for manual transactions.""" | ||||
|     def __init__(self): | ||||
|         self.conn = None | ||||
|         self.cursor = None | ||||
| 
 | ||||
|     def __enter__(self): | ||||
|         self.conn = get_connection() | ||||
|         self.cursor = self.conn.cursor() | ||||
|         return self.cursor | ||||
| 
 | ||||
|     def __exit__(self, exc_type, exc_val, exc_tb): | ||||
|         try: | ||||
|             if exc_type: | ||||
|                 self.conn.rollback() | ||||
|             else: | ||||
|                 self.conn.commit() | ||||
|         finally: | ||||
|             if self.cursor: | ||||
|                 self.cursor.close() | ||||
|             if self.conn: | ||||
|                 conn_pool.putconn(self.conn) | ||||
|  | @ -3,3 +3,6 @@ flask | |||
| python-dotenv | ||||
| Werkzeug | ||||
| 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 { | ||||
|     font-family: Arial, sans-serif; | ||||
|   font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; | ||||
|   color: var(--text); | ||||
|   background: radial-gradient(1000px 650px at 0% 0%, var(--bg-grad-1), transparent 60%), | ||||
|               radial-gradient(900px 600px at 100% 0%, var(--bg-grad-2), transparent 55%), | ||||
|               var(--bg); | ||||
|   min-height: 100vh; | ||||
| } | ||||
| 
 | ||||
| .navbar { | ||||
|   position: sticky; | ||||
|   top: 0; | ||||
|   z-index: 1000; | ||||
|   background: rgba(255, 255, 255, 0.8); | ||||
|   backdrop-filter: blur(10px); | ||||
|   border-bottom: 1px solid rgba(15, 23, 42, 0.06); | ||||
| } | ||||
| .navbar-content { | ||||
|   max-width: 1100px; | ||||
|   margin: 0 auto; | ||||
|   padding: 14px 20px; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: space-between; | ||||
| } | ||||
| .brand { | ||||
|   color: var(--text); | ||||
|   font-weight: 700; | ||||
|   text-decoration: none; | ||||
|   letter-spacing: 0.2px; | ||||
| } | ||||
| .navbar-links a { | ||||
|   color: var(--muted); | ||||
|   text-decoration: none; | ||||
|   margin-left: 14px; | ||||
|   padding: 8px 12px; | ||||
|   border-radius: 10px; | ||||
|   transition: all 0.2s ease; | ||||
| } | ||||
| .navbar-links a:hover { | ||||
|   color: var(--text); | ||||
|   background: rgba(15, 23, 42, 0.05); | ||||
| } | ||||
| .navbar-links .logout { | ||||
|   color: #b91c1c; | ||||
| } | ||||
| .navbar-links .logout:hover { | ||||
|   background: rgba(185, 28, 28, 0.08); | ||||
| } | ||||
| 
 | ||||
| .container { | ||||
|   max-width: 1100px; | ||||
|   margin: 28px auto 48px; | ||||
|   padding: 0 20px; | ||||
|   padding-bottom: 64px; | ||||
| } | ||||
| 
 | ||||
| .footer { | ||||
|   position: fixed; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   z-index: 1000; | ||||
|   border-top: 1px solid rgba(15, 23, 42, 0.06); | ||||
|   background: rgba(255, 255, 255, 0.92); | ||||
|   backdrop-filter: blur(8px); | ||||
| } | ||||
| 
 | ||||
| .footer-inner { | ||||
|   max-width: 1100px; | ||||
|   margin: 0 auto; | ||||
|   padding: 14px 20px; | ||||
|   color: var(--muted); | ||||
|   font-size: 14px; | ||||
| } | ||||
| 
 | ||||
| .page-header { | ||||
|   margin-bottom: 18px; | ||||
|   display: flex; | ||||
|   align-items: end; | ||||
|   justify-content: space-between; | ||||
|   gap: 12px; | ||||
|   flex-wrap: wrap; | ||||
| } | ||||
| .page-title { | ||||
|   margin: 0 0 4px 0; | ||||
|   font-size: 26px; | ||||
|   font-weight: 700; | ||||
| } | ||||
| .page-subtitle { | ||||
|   margin: 0; | ||||
|   color: var(--muted); | ||||
|   font-size: 14px; | ||||
| } | ||||
| .page-actions { | ||||
|   display: flex; | ||||
|   gap: 10px; | ||||
| } | ||||
| 
 | ||||
| .card { | ||||
|   background: var(--surface); | ||||
|   border: 1px solid var(--border); | ||||
|   border-radius: var(--radius); | ||||
|   box-shadow: var(--shadow-1); | ||||
|   padding: 20px; | ||||
| } | ||||
|    | ||||
|   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; | ||||
| .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; | ||||
|   } | ||||
| } | ||||
|  | @ -1,29 +1,35 @@ | |||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| <head> | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|     <title>Admin Center - Subscribers</title> | ||||
|     <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> | ||||
| </head> | ||||
| <body> | ||||
| {% extends "base.html" %} | ||||
| {% block title %}Dashboard{% endblock %} | ||||
| {% block content %} | ||||
|   <div class="page-header"> | ||||
|     <div> | ||||
|       <h1 class="page-title">Dashboard</h1> | ||||
|       <p class="page-subtitle">Quick overview of your mailing activity</p> | ||||
|     </div> | ||||
|     <div class="page-actions"> | ||||
|       <a href="{{ url_for('send_update') }}" class="button button-primary">Send Update</a> | ||||
|     </div> | ||||
|   </div> | ||||
| 
 | ||||
|     <h1>Subscribers</h1> | ||||
|     <p> | ||||
|       <a href="{{ url_for('send_update') }}">Send Update Email</a>| | ||||
|       <a href="{{ url_for('logout') }}">Logout</a> | ||||
|     </p> | ||||
| 
 | ||||
|     {% with messages = get_flashed_messages(with_categories=true) %} | ||||
|       {% if messages %} | ||||
|         {% for category, message in messages %} | ||||
|           <div class="flash">{{ message }}</div> | ||||
|         {% endfor %} | ||||
|       {% endif %} | ||||
|     {% endwith %} | ||||
|   <section class="widgets"> | ||||
|     <div class="widget-card"> | ||||
|       <div class="widget-label">Total Subscribers</div> | ||||
|       <div class="widget-value">{{ counts.total_subscribers }}</div> | ||||
|     </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> | ||||
| 
 | ||||
|   {% if emails %} | ||||
|         <table> | ||||
|     <div class="card"> | ||||
|       <div class="table-wrap"> | ||||
|         <table class="table"> | ||||
|           <thead> | ||||
|             <tr> | ||||
|               <th>Email Address</th> | ||||
|  | @ -37,8 +43,11 @@ | |||
|             {% endfor %} | ||||
|           </tbody> | ||||
|         </table> | ||||
|       </div> | ||||
|     </div> | ||||
|   {% else %} | ||||
|     <div class="card empty-state"> | ||||
|       <p>No subscribers found.</p> | ||||
|     </div> | ||||
|   {% endif %} | ||||
| </body> | ||||
| </html> | ||||
| {% 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> | ||||
| <html lang="en"> | ||||
| {% extends "base.html" %} | ||||
| {% 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> | ||||
|   <meta charset="UTF-8"> | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|   <title>Admin Login</title> | ||||
|   <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> | ||||
| </head> | ||||
| 
 | ||||
| <body> | ||||
|   <h1>Admin Login</h1> | ||||
|   {% with messages = get_flashed_messages(with_categories=true) %} | ||||
|   {% if messages %} | ||||
|   {% for category, message in messages %} | ||||
|   <div class="flash">{{ message }}</div> | ||||
|   {% endfor %} | ||||
|   {% endif %} | ||||
|   {% endwith %} | ||||
|   <form action="{{ url_for('login') }}" method="POST"> | ||||
|     <label for="username">Username:</label> | ||||
|     <input type="text" name="username" required /> | ||||
|     <label for="password">Password:</label> | ||||
|     <input type="password" name="password" required /> | ||||
|     <button type="submit">Login</button> | ||||
|       <form action="{{ url_for('login') }}" method="POST" class="form"> | ||||
|         <div class="form-group"> | ||||
|           <label for="username">Username</label> | ||||
|           <input | ||||
|             type="text" | ||||
|             name="username" | ||||
|             id="username" | ||||
|             autocomplete="username" | ||||
|             required | ||||
|           /> | ||||
|         </div> | ||||
|         <div class="form-group"> | ||||
|           <label for="password">Password</label> | ||||
|           <input | ||||
|             type="password" | ||||
|             name="password" | ||||
|             id="password" | ||||
|             autocomplete="current-password" | ||||
|             required | ||||
|           /> | ||||
|         </div> | ||||
|         <button type="submit" class="button button-primary">Login</button> | ||||
|       </form> | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
|     </div> | ||||
|   </section> | ||||
| {% endblock %} | ||||
|  | @ -1,37 +1,32 @@ | |||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| {% extends "base.html" %} | ||||
| {% 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> | ||||
|   <meta charset="UTF-8"> | ||||
|   <meta name="viewport" content="width=device-width, initial-scale=1.0"> | ||||
|   <title>Admin Center - Send Update</title> | ||||
|   <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> | ||||
| </head> | ||||
|   <div class="card"> | ||||
|     <form action="{{ url_for('send_update') }}" method="POST" class="form"> | ||||
|       <div class="form-group"> | ||||
|         <label for="subject">Subject</label> | ||||
|         <input type="text" name="subject" id="subject" required /> | ||||
|       </div> | ||||
| 
 | ||||
| <body> | ||||
|   <h1>Send Update Email</h1> | ||||
|   <p> | ||||
|     <a href="{{ url_for('index') }}">Back to Subscribers List</a> | | ||||
|     <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-group"> | ||||
|         <label for="body">Body (HTML allowed)</label> | ||||
|         <textarea name="body" id="body" rows="12" required | ||||
|           placeholder="<h1>Title</h1><p>Your content...</p>"></textarea> | ||||
|       </div> | ||||
| 
 | ||||
|   <form action="{{ url_for('send_update') }}" method="POST"> | ||||
|     <label for="subject">Subject:</label> | ||||
|     <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> | ||||
|       <div class="form-actions"> | ||||
|         <button type="submit" class="button button-primary">Send Update</button> | ||||
|       </div> | ||||
|     </form> | ||||
| 
 | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
|   </div> | ||||
| {% endblock %} | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue