Compare commits
	
		
			13 commits
		
	
	
		
			go-rewrite
			...
			main
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 803471b914 | ||
|   | 797266226b | ||
|   | 17fe0bf79f | ||
|   | f2d225c56a | ||
|   | 941a3dabc9 | ||
|   | 49ab3c1fe4 | ||
|   | 4f059fd0e1 | ||
|   | d9c86aa1bb | ||
|   | a8589b659f | ||
|   | e36bdc4568 | ||
|   | 9d78f1fdb4 | ||
|   | db4a293fe1 | ||
|   | 5a3dd86bf9 | 
					 15 changed files with 849 additions and 393 deletions
				
			
		
							
								
								
									
										8
									
								
								.idea/.gitignore
									
										
									
										generated
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								.idea/.gitignore
									
										
									
										generated
									
									
										vendored
									
									
								
							|  | @ -1,8 +0,0 @@ | |||
| # Default ignored files | ||||
| /shelf/ | ||||
| /workspace.xml | ||||
| # Editor-based HTTP Client requests | ||||
| /httpRequests/ | ||||
| # Datasource local storage ignored files | ||||
| /dataSources/ | ||||
| /dataSources.local.xml | ||||
							
								
								
									
										21
									
								
								.idea/admin-panel.iml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										21
									
								
								.idea/admin-panel.iml
									
										
									
										generated
									
									
									
								
							|  | @ -1,21 +0,0 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <module type="PYTHON_MODULE" version="4"> | ||||
|   <component name="Flask"> | ||||
|     <option name="enabled" value="true" /> | ||||
|   </component> | ||||
|   <component name="NewModuleRootManager"> | ||||
|     <content url="file://$MODULE_DIR$"> | ||||
|       <excludeFolder url="file://$MODULE_DIR$/.venv" /> | ||||
|     </content> | ||||
|     <orderEntry type="jdk" jdkName="Python 3.11 (admin-panel)" jdkType="Python SDK" /> | ||||
|     <orderEntry type="sourceFolder" forTests="false" /> | ||||
|   </component> | ||||
|   <component name="TemplatesService"> | ||||
|     <option name="TEMPLATE_CONFIGURATION" value="Jinja2" /> | ||||
|     <option name="TEMPLATE_FOLDERS"> | ||||
|       <list> | ||||
|         <option value="$MODULE_DIR$/templates" /> | ||||
|       </list> | ||||
|     </option> | ||||
|   </component> | ||||
| </module> | ||||
							
								
								
									
										6
									
								
								.idea/inspectionProfiles/profiles_settings.xml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								.idea/inspectionProfiles/profiles_settings.xml
									
										
									
										generated
									
									
									
								
							|  | @ -1,6 +0,0 @@ | |||
| <component name="InspectionProjectProfileManager"> | ||||
|   <settings> | ||||
|     <option name="USE_PROJECT_PROFILE" value="false" /> | ||||
|     <version value="1.0" /> | ||||
|   </settings> | ||||
| </component> | ||||
							
								
								
									
										6
									
								
								.idea/misc.xml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								.idea/misc.xml
									
										
									
										generated
									
									
									
								
							|  | @ -1,6 +0,0 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <project version="4"> | ||||
|   <component name="Black"> | ||||
|     <option name="sdkName" value="Python 3.11 (admin-panel)" /> | ||||
|   </component> | ||||
| </project> | ||||
							
								
								
									
										8
									
								
								.idea/modules.xml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								.idea/modules.xml
									
										
									
										generated
									
									
									
								
							|  | @ -1,8 +0,0 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | ||||
| <project version="4"> | ||||
|   <component name="ProjectModuleManager"> | ||||
|     <modules> | ||||
|       <module fileurl="file://$PROJECT_DIR$/.idea/admin-panel.iml" filepath="$PROJECT_DIR$/.idea/admin-panel.iml" /> | ||||
|     </modules> | ||||
|   </component> | ||||
| </project> | ||||
							
								
								
									
										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"] | ||||
							
								
								
									
										113
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										113
									
								
								README.md
									
										
									
									
									
								
							|  | @ -1,57 +1,87 @@ | |||
| # RideAware Admin Center | ||||
| 
 | ||||
| This project provides an Admin Center for managing the RideAware subscriber list. It connects to the same SQLite database (`subscribers.db`) used by the landing page app (running on port 5000) and allows an administrator to: | ||||
| 
 | ||||
| - View all currently subscribed email addresses. | ||||
| - Send overview update emails to all subscribers. | ||||
| - Unsubscribe emails via the landing page app. | ||||
| 
 | ||||
| The Admin Center is protected by a login page, and admin credentials (with a salted/hashed password) are stored in the `admin_users` table within the same database. | ||||
| This project provides a secure and user-friendly Admin Panel for managing RideAware subscribers and sending update emails. It's designed to work in conjunction with the RideAware landing page application, utilizing a shared database for subscriber management. | ||||
| 
 | ||||
| ## Features | ||||
| 
 | ||||
| - **Admin Login:**   | ||||
|   Secure login using salted and hashed passwords (via Werkzeug security utilities). | ||||
| 
 | ||||
| - **Subscriber List:**   | ||||
|   View all email addresses currently stored in the `subscribers` table. | ||||
| **Secure Admin Authentication:** | ||||
| 
 | ||||
| - **Email Updates:**   | ||||
|   A form for sending update emails (HTML allowed) to the subscriber list using SMTP. | ||||
| * Login protected by username/password authentication using Werkzeug's password hashing. | ||||
| * Default admin credentials configurable via environment variables. | ||||
| 
 | ||||
| - **Shared Database:**   | ||||
|   Both the landing page app (port 5000) and Admin Center (port 5001) connect to the same `subscribers.db`. | ||||
| **Subscriber Management:** | ||||
| * View a comprehensive list of all subscribed email addresses. | ||||
| 
 | ||||
| ## Setup & Running | ||||
| **Email Marketing:** | ||||
| * Compose and send HTML-rich update emails to all subscribers. | ||||
| * Supports embedding unsubscribe links in email content for easy opt-out. | ||||
| 
 | ||||
| **Shared Database:** | ||||
| * Utilizes a shared PostgreSQL database with the landing page application for consistent subscriber data. | ||||
| 
 | ||||
| **Centralized Newsletter Storage:** | ||||
| * Storage of newsletter subject and email bodies in the PostgreSQL database | ||||
| 
 | ||||
| **Logging:** | ||||
| * Implemented comprehensive logging throughout the application for better monitoring and debugging. | ||||
| 
 | ||||
| ## Architecture | ||||
| 
 | ||||
| The Admin Panel is built using Python with the Flask web framework, using the following technologies: | ||||
| 
 | ||||
| *   **Backend:** Python 3.11+, Flask | ||||
| *   **Database:** PostgreSQL | ||||
| *   **Template Engine:** Jinja2 | ||||
| *   **Authentication:** Werkzeug Security | ||||
| *   **Email:** SMTP (using `smtplib`) | ||||
| *   **Containerization:** Docker | ||||
| *   **Configuration:** .env file using `python-dotenv` | ||||
| 
 | ||||
| ## Setup & Deployment | ||||
| 
 | ||||
| ### Prerequisites | ||||
| 
 | ||||
| - Docker (for containerized deployment) | ||||
| - Python 3.11+ (if running locally without Docker) | ||||
| - An SMTP account (e.g., Spacemail) for sending emails | ||||
| - A `.env` file with configuration details | ||||
| *   Docker (recommended for containerized deployment) | ||||
| *   Python 3.11+ (if running locally without Docker) | ||||
| *   A PostgreSQL database instance | ||||
| *   An SMTP account (e.g., SendGrid, Mailgun) for sending emails | ||||
| *   A `.env` file with configuration details | ||||
| 
 | ||||
| ### .env Configuration | ||||
| 
 | ||||
| Create a `.env` file in the project root with the following example variables: | ||||
| Create a `.env` file in the project root directory with the following environment variables.  Make sure to replace the placeholder values with your actual credentials. | ||||
| 
 | ||||
| ```env | ||||
| # SMTP settings (shared with the landing page app) | ||||
| SMTP_SERVER=<email server> | ||||
| SMTP_PORT=<email port> | ||||
| SMTP_USER=<email username> | ||||
| SMTP_PASSWORD=<email password> | ||||
| # Flask Application | ||||
| SECRET_KEY="YourSecretKeyHere" #Used to sign session cookies | ||||
| 
 | ||||
| # Database file | ||||
| DATABASE_FILE=subscribers.db | ||||
| # PostgreSQL Database Configuration | ||||
| PG_HOST=localhost | ||||
| PG_PORT=5432 | ||||
| PG_DATABASE=rideaware_db | ||||
| PG_USER=rideaware_user | ||||
| PG_PASSWORD=rideaware_password | ||||
| 
 | ||||
| # Admin credentials for the Admin Center | ||||
| ADMIN_USERNAME=admin | ||||
| ADMIN_PASSWORD="changeme"  # Change this to a secure password | ||||
| ADMIN_SECRET_KEY="your_super_secret_key" | ||||
| 
 | ||||
| # SMTP Email Settings | ||||
| SMTP_SERVER=smtp.example.com | ||||
| SMTP_PORT=465 #Or another appropriate port | ||||
| SMTP_USER=your_email@example.com | ||||
| SMTP_PASSWORD=YourEmailPassword | ||||
| SENDER_EMAIL=your_email@example.com #Email to send emails from | ||||
| BASE_URL="your_site_domain.com" # used for unsubscribe links, example.com not https:// | ||||
| 
 | ||||
| #Used for debugging | ||||
| FLASK_DEBUG=1 | ||||
| ``` | ||||
| 
 | ||||
| ### Running with Docker | ||||
| ### Running with Docker (Recommended) | ||||
| 
 | ||||
| This is the recommended approach for deploying the RideAware Admin Panel | ||||
| 
 | ||||
| Building the Docker image: | ||||
| ```sh | ||||
|  | @ -63,18 +93,35 @@ Running the container mapping port 5001: | |||
| docker run -p 5001:5001 admin-panel | ||||
| ``` | ||||
| 
 | ||||
| The app will be accessible at http://ip-address-here:5001 | ||||
| The application will be accessible at `http://localhost:5001` or `http://<your_server_ip>:5001` | ||||
| 
 | ||||
| *Note: When running locally with Docker, ensure the .env file is located at the project root. Alternatively, you can pass the variables in the CLI.* | ||||
| 
 | ||||
| ### Running locally | ||||
| Install the dependencies using **requirements.txt**: | ||||
| 
 | ||||
| Install Dependencies: | ||||
| 
 | ||||
| ```sh | ||||
| pip install -r requirements.txt | ||||
| ``` | ||||
| 
 | ||||
| Set Environment Variables: | ||||
| 
 | ||||
| Ensure all the environment variables specified in the `.env` configuration section are set in your shell environment. You can source the `.env` file: | ||||
| 
 | ||||
| 
 | ||||
| Then run the admin app: | ||||
| ```sh | ||||
| python app.py | ||||
| ``` | ||||
| 
 | ||||
| The app will be accessible at http://ip-address-here:5001 | ||||
| The app will be accessible at `http://127.0.0.1:5001` | ||||
| 
 | ||||
| ### Contributing | ||||
| 
 | ||||
| Contributions to the RideAware Admin Panel are welcome! Please follow these steps: | ||||
| 
 | ||||
| * Fork the repository. | ||||
| * Create a new branch for your feature or bug fix. | ||||
| * Make your changes and commit them with descriptive commit messages. | ||||
| * Submit a pull request. | ||||
							
								
								
									
										244
									
								
								app.py
									
										
									
									
									
								
							
							
						
						
									
										244
									
								
								app.py
									
										
									
									
									
								
							|  | @ -1,7 +1,9 @@ | |||
| import os | ||||
| import logging | ||||
| import smtplib | ||||
| from email.mime.text import MIMEText | ||||
| from functools import wraps | ||||
| from urllib.parse import urlparse, urljoin | ||||
| 
 | ||||
| from flask import ( | ||||
|     Flask, | ||||
|     render_template, | ||||
|  | @ -11,135 +13,222 @@ from flask import ( | |||
|     flash, | ||||
|     session, | ||||
| ) | ||||
| from markupsafe import escape | ||||
| from dotenv import load_dotenv | ||||
| from werkzeug.security import check_password_hash | ||||
| from functools import wraps  # Import wraps | ||||
| from database import get_connection, init_db, get_all_emails, get_admin, create_default_admin | ||||
| 
 | ||||
| from database import ( | ||||
|     get_connection, | ||||
|     init_db, | ||||
|     get_all_emails, | ||||
|     get_admin, | ||||
|     create_default_admin, | ||||
| ) | ||||
| 
 | ||||
| load_dotenv() | ||||
| 
 | ||||
| app = Flask(__name__) | ||||
| app.secret_key = os.getenv("SECRET_KEY") | ||||
| base_url = os.getenv("BASE_URL") | ||||
| base_url = os.getenv("BASE_URL", "").strip().strip("/") | ||||
| 
 | ||||
| # SMTP settings (for sending update emails) | ||||
| SMTP_SERVER = os.getenv("SMTP_SERVER") | ||||
| SMTP_PORT = int(os.getenv("SMTP_PORT", 465)) | ||||
| SMTP_USER = os.getenv("SMTP_USER") | ||||
| SMTP_PASSWORD = os.getenv("SMTP_PASSWORD") | ||||
| SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) # Use SENDER_EMAIL | ||||
| SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) | ||||
| 
 | ||||
| # Logging setup | ||||
| logging.basicConfig( | ||||
|     level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" | ||||
| ) | ||||
| logger = logging.getLogger(__name__) | ||||
| 
 | ||||
| # Initialize the database and create default admin user if necessary. | ||||
| init_db() | ||||
| create_default_admin() | ||||
| 
 | ||||
| # Decorator for requiring login | ||||
| 
 | ||||
| def login_required(f): | ||||
|     @wraps(f)  # Use wraps to preserve function metadata | ||||
|     @wraps(f) | ||||
|     def decorated_function(*args, **kwargs): | ||||
|         if "username" not in session: | ||||
|             return redirect(url_for("login")) | ||||
|             next_url = request.full_path if request.query_string else request.path | ||||
|             return redirect(url_for("login", next=next_url)) | ||||
|         return f(*args, **kwargs) | ||||
| 
 | ||||
|     return decorated_function | ||||
| 
 | ||||
| 
 | ||||
| def send_update_email(subject, body, email): | ||||
|     """Sends email, returns True on success, False on failure.""" | ||||
| def get_dashboard_counts(): | ||||
|     """Return dict of counts: total subscribers, total newsletters, sent today.""" | ||||
|     counts = {"total_subscribers": 0, "total_newsletters": 0, "sent_today": 0} | ||||
|     try: | ||||
|         server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) | ||||
|         server.set_debuglevel(False)  # Keep debug level at False for production | ||||
|         server.login(SMTP_USER, SMTP_PASSWORD) | ||||
| 
 | ||||
|         unsub_link = f"https://{base_url}/unsubscribe?email={email}" | ||||
|         custom_body = ( | ||||
|             f"{body}<br><br>" | ||||
|             f"If you ever wish to unsubscribe, please click <a href='{unsub_link}'>here</a>" | ||||
|         ) | ||||
| 
 | ||||
|         msg = MIMEText(custom_body, "html", "utf-8") | ||||
|         msg["Subject"] = subject | ||||
|         msg["From"] = SENDER_EMAIL  # Use sender email | ||||
|         msg["To"] = email | ||||
| 
 | ||||
|         server.sendmail(SENDER_EMAIL, email, msg.as_string())  # Use sender email | ||||
| 
 | ||||
|         server.quit() | ||||
|         logger.info(f"Update email sent to: {email}") | ||||
|         return True | ||||
|     except Exception as e: | ||||
|         logger.error(f"Failed to send email to {email}: {e}") | ||||
|         return False | ||||
| 
 | ||||
| 
 | ||||
| def process_send_update_email(subject, body): | ||||
|     """Helper function to send an update email to all subscribers.""" | ||||
|     subscribers = get_all_emails() | ||||
|     if not subscribers: | ||||
|         return "No subscribers found." | ||||
|     try: | ||||
|         for email in subscribers: | ||||
|             if not send_update_email(subject, body, email): | ||||
|                 return f"Failed to send to {email}"  # Specific failure message | ||||
| 
 | ||||
|         # Log newsletter content for audit purposes | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute( | ||||
|             "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", (subject, body) | ||||
|         ) | ||||
|         conn.commit() | ||||
|         cursor.close() | ||||
|         conn.close() | ||||
|         cur = conn.cursor() | ||||
|         cur.execute("SELECT COUNT(*) FROM subscribers") | ||||
|         counts["total_subscribers"] = cur.fetchone()[0] or 0 | ||||
| 
 | ||||
|         cur.execute("SELECT COUNT(*) FROM newsletters") | ||||
|         counts["total_newsletters"] = cur.fetchone()[0] or 0 | ||||
| 
 | ||||
|         cur.execute( | ||||
|             """ | ||||
|             SELECT COUNT(*) | ||||
|             FROM newsletters | ||||
|             WHERE sent_at::date = CURRENT_DATE | ||||
|             """ | ||||
|         ) | ||||
|         counts["sent_today"] = cur.fetchone()[0] or 0 | ||||
| 
 | ||||
|         cur.close() | ||||
|         conn.close() | ||||
|     except Exception: | ||||
|         pass | ||||
|     return counts | ||||
| 
 | ||||
| 
 | ||||
| def is_safe_url(target: str) -> bool: | ||||
|     if not target: | ||||
|         return False | ||||
|     ref = urlparse(request.host_url) | ||||
|     test = urlparse(urljoin(request.host_url, target)) | ||||
|     return test.scheme in ("http", "https") and ref.netloc == test.netloc | ||||
| 
 | ||||
| 
 | ||||
| def send_update_email(subject: str, body_html: str, email: str) -> bool: | ||||
|     """Send a single HTML email with retries.""" | ||||
|     max_retries = 3 | ||||
|     retry_count = 0 | ||||
| 
 | ||||
|     unsub_link = "" | ||||
|     if base_url: | ||||
|         unsub_link = f"https://{base_url}/unsubscribe?email={email}" | ||||
| 
 | ||||
|     if unsub_link: | ||||
|         custom_body = ( | ||||
|             f"{body_html}" | ||||
|             f"<br><br>" | ||||
|             f"If you ever wish to unsubscribe, please click " | ||||
|             f"<a href='{unsub_link}'>here</a>." | ||||
|         ) | ||||
|     else: | ||||
|         custom_body = body_html | ||||
| 
 | ||||
|     while retry_count < max_retries: | ||||
|         try: | ||||
|             server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10) | ||||
|             server.set_debuglevel(0) | ||||
|             server.login(SMTP_USER, SMTP_PASSWORD) | ||||
| 
 | ||||
|             msg = MIMEText(custom_body, "html", "utf-8") | ||||
|             msg["Subject"] = subject | ||||
|             msg["From"] = SENDER_EMAIL | ||||
|             msg["To"] = email | ||||
| 
 | ||||
|             server.sendmail(SENDER_EMAIL, [email], msg.as_string()) | ||||
|             server.quit() | ||||
|             return True | ||||
|         except Exception: | ||||
|             retry_count += 1 | ||||
|             if retry_count >= max_retries: | ||||
|                 break | ||||
|             import time | ||||
| 
 | ||||
|             time.sleep(1.0) | ||||
| 
 | ||||
|     return False | ||||
| 
 | ||||
| 
 | ||||
| def process_send_update_email(subject: str, body_html: str) -> str: | ||||
|     """Send update email to all subscribers and log newsletter content.""" | ||||
|     try: | ||||
|         subscribers = get_all_emails() | ||||
|         if not subscribers: | ||||
|             return "No subscribers found." | ||||
| 
 | ||||
|         failures = [] | ||||
|         for email in subscribers: | ||||
|             if not send_update_email(subject, body_html, email): | ||||
|                 failures.append(email) | ||||
| 
 | ||||
|         try: | ||||
|             conn = get_connection() | ||||
|             cursor = conn.cursor() | ||||
|             cursor.execute( | ||||
|                 "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", | ||||
|                 (subject, body_html), | ||||
|             ) | ||||
|             conn.commit() | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         except Exception: | ||||
|             pass | ||||
| 
 | ||||
|         if failures: | ||||
|             return f"Sent with failures: {len(failures)} recipients failed." | ||||
|         return "Email has been sent to all subscribers." | ||||
|     except Exception as e: | ||||
|         logger.exception("Error processing sending updates") | ||||
|         return f"Failed to send email: {e}" | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/") | ||||
| @app.route("/", methods=["GET"]) | ||||
| @login_required | ||||
| def index(): | ||||
|     """Displays all subscriber emails""" | ||||
|     emails = get_all_emails() | ||||
|     return render_template("admin_index.html", emails=emails) | ||||
|     """Dashboard: list subscriber emails and show widgets.""" | ||||
|     emails = [] | ||||
|     try: | ||||
|         emails = get_all_emails() | ||||
|     except Exception: | ||||
|         flash("Could not load subscribers right now.", "danger") | ||||
| 
 | ||||
|     counts = get_dashboard_counts() | ||||
|     return render_template("admin_index.html", emails=emails, counts=counts) | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/send_update", methods=["GET", "POST"]) | ||||
| @login_required | ||||
| def send_update(): | ||||
|     """Display a form to send an update email; process submission on POST.""" | ||||
|     if request.method == "POST": | ||||
|         subject = request.form["subject"] | ||||
|         body = request.form["body"] | ||||
|         result_message = process_send_update_email(subject, body) | ||||
|         flash(result_message) | ||||
|         subject = (request.form.get("subject") or "").strip() | ||||
|         body_html = request.form.get("body") or "" | ||||
| 
 | ||||
|         if not subject or not body_html: | ||||
|             flash("Subject and body are required", "danger") | ||||
|             return redirect(url_for("send_update")) | ||||
| 
 | ||||
|         result_message = process_send_update_email(subject, body_html) | ||||
|         flash(escape(result_message)) | ||||
|         return redirect(url_for("send_update")) | ||||
| 
 | ||||
|     return render_template("send_update.html") | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/login", methods=["GET", "POST"]) | ||||
| def login(): | ||||
|     if request.method == "POST": | ||||
|         username = request.form.get("username") | ||||
|         password = request.form.get("password") | ||||
|         username = (request.form.get("username") or "").strip() | ||||
|         password = (request.form.get("password") or "").strip() | ||||
| 
 | ||||
|         if not username or not password: | ||||
|             flash("Username and password are required", "danger") | ||||
|             return redirect(url_for("login")) | ||||
| 
 | ||||
|         admin = get_admin(username) | ||||
|         if admin and check_password_hash(admin[1], password): | ||||
|             session["username"] = username | ||||
|             session.permanent = True | ||||
|             app.config["SESSION_COOKIE_HTTPONLY"] = True | ||||
|             app.config["SESSION_COOKIE_SECURE"] = True | ||||
|             app.config["SESSION_COOKIE_SAMESITE"] = "Lax" | ||||
| 
 | ||||
|             next_url = request.args.get("next") | ||||
|             if next_url and is_safe_url(next_url): | ||||
|                 flash("Logged in successfully", "success") | ||||
|                 return redirect(next_url) | ||||
| 
 | ||||
|             flash("Logged in successfully", "success") | ||||
|             return redirect(url_for("index")) | ||||
|         else: | ||||
|             flash("Invalid username or password", "danger") | ||||
|             return redirect(url_for("login")) | ||||
| 
 | ||||
|     return render_template("login.html") | ||||
| 
 | ||||
| 
 | ||||
| @app.route("/logout") | ||||
| @app.route("/logout", methods=["GET"]) | ||||
| def logout(): | ||||
|     session.pop("username", None) | ||||
|     flash("Logged out successfully", "success") | ||||
|  | @ -147,4 +236,9 @@ def logout(): | |||
| 
 | ||||
| 
 | ||||
| if __name__ == "__main__": | ||||
|     app.run(port=5001, debug=True) | ||||
|     is_prod = os.getenv("ENVIRONMENT", "development").lower() == "production" | ||||
|     app.config["PREFERRED_URL_SCHEME"] = "https" if is_prod else "http" | ||||
|     if is_prod: | ||||
|         app.run(host="0.0.0.0", port=5001, debug=False, use_reloader=False) | ||||
|     else: | ||||
|         app.run(host="0.0.0.0", port=5001, debug=True, use_reloader=True) | ||||
							
								
								
									
										179
									
								
								database.py
									
										
									
									
									
								
							
							
						
						
									
										179
									
								
								database.py
									
										
									
									
									
								
							|  | @ -1,54 +1,53 @@ | |||
| import os | ||||
| import logging | ||||
| import psycopg2 | ||||
| from psycopg2 import IntegrityError | ||||
| from psycopg2 import IntegrityError, pool, OperationalError | ||||
| from dotenv import load_dotenv | ||||
| from werkzeug.security import generate_password_hash | ||||
| 
 | ||||
| load_dotenv() | ||||
| 
 | ||||
| # Logging setup | ||||
| logging.basicConfig( | ||||
|     level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s" | ||||
| ) | ||||
| logger = logging.getLogger(__name__) | ||||
| try: | ||||
|     DB_MIN_CONN = int(os.getenv("DB_MIN_CONN", 1)) | ||||
|     DB_MAX_CONN = int(os.getenv("DB_MAX_CONN", 10)) | ||||
| 
 | ||||
|     conn_pool = pool.ThreadedConnectionPool( | ||||
|         minconn=DB_MIN_CONN, | ||||
|         maxconn=DB_MAX_CONN, | ||||
|         host=os.getenv("PG_HOST"), | ||||
|         port=os.getenv("PG_PORT"), | ||||
|         dbname=os.getenv("PG_DATABASE"), | ||||
|         user=os.getenv("PG_USER"), | ||||
|         password=os.getenv("PG_PASSWORD"), | ||||
|         connect_timeout=10, | ||||
|     ) | ||||
| except OperationalError: | ||||
|     raise | ||||
| except Exception: | ||||
|     raise | ||||
| 
 | ||||
| 
 | ||||
| def get_connection(): | ||||
|     """Return a new connection to the PostgreSQL database.""" | ||||
|     try: | ||||
|         conn = psycopg2.connect( | ||||
|             host=os.getenv("PG_HOST"), | ||||
|             port=os.getenv("PG_PORT"), | ||||
|             dbname=os.getenv("PG_DATABASE"), | ||||
|             user=os.getenv("PG_USER"), | ||||
|             password=os.getenv("PG_PASSWORD"), | ||||
|             connect_timeout=10, | ||||
|         ) | ||||
|         return conn | ||||
|     except Exception as e: | ||||
|         logger.error(f"Database connection error: {e}") | ||||
|         raise | ||||
|     """Get a connection from the connection pool.""" | ||||
|     return conn_pool.getconn() | ||||
| 
 | ||||
| 
 | ||||
| def init_db(): | ||||
|     """Initialize the database tables.""" | ||||
|     """Initialize database tables with connection pool.""" | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
| 
 | ||||
|         # Create subscribers table (if not exists) | ||||
|         cursor.execute( | ||||
|             """ | ||||
|             CREATE TABLE IF NOT EXISTS subscribers ( | ||||
|                 id SERIAL PRIMARY KEY, | ||||
|                 email TEXT UNIQUE NOT NULL | ||||
|             ) | ||||
|         """ | ||||
|             """ | ||||
|         ) | ||||
| 
 | ||||
|         # Create admin_users table (if not exists) | ||||
|         cursor.execute( | ||||
|             """ | ||||
|             CREATE TABLE IF NOT EXISTS admin_users ( | ||||
|  | @ -56,94 +55,96 @@ def init_db(): | |||
|                 username TEXT UNIQUE NOT NULL, | ||||
|                 password TEXT NOT NULL | ||||
|             ) | ||||
|         """ | ||||
|             """ | ||||
|         ) | ||||
| 
 | ||||
|         # Newsletter storage | ||||
|         cursor.execute( | ||||
|             """ | ||||
|         CREATE TABLE IF NOT EXISTS newsletters ( | ||||
|             id SERIAL PRIMARY KEY, | ||||
|             subject TEXT NOT NULL, | ||||
|             body TEXT NOT NULL, | ||||
|             sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP | ||||
|         ) | ||||
|         """ | ||||
|             CREATE TABLE IF NOT EXISTS newsletters ( | ||||
|                 id SERIAL PRIMARY KEY, | ||||
|                 subject TEXT NOT NULL, | ||||
|                 body TEXT NOT NULL, | ||||
|                 sent_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP | ||||
|             ) | ||||
|             """ | ||||
|         ) | ||||
| 
 | ||||
|         conn.commit() | ||||
|         logger.info("Database initialized successfully.") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Database initialization error: {e}") | ||||
|     except Exception: | ||||
|         if conn: | ||||
|             conn.rollback()  # Rollback if there's an error | ||||
| 
 | ||||
|             conn.rollback() | ||||
|         raise | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def get_all_emails(): | ||||
|     """Return a list of all subscriber emails.""" | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute("SELECT email FROM subscribers") | ||||
|         results = cursor.fetchall() | ||||
|         emails = [row[0] for row in results] | ||||
|         logger.debug(f"Retrieved emails: {emails}") | ||||
|         return emails | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error retrieving emails: {e}") | ||||
|         return [row[0] for row in results] | ||||
|     except Exception: | ||||
|         return [] | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def add_email(email): | ||||
|     """Insert an email into the subscribers table.""" | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,)) | ||||
|         conn.commit() | ||||
|         logger.info(f"Email {email} added successfully.") | ||||
|         return True | ||||
|     except IntegrityError: | ||||
|         logger.warning(f"Attempted to add duplicate email: {email}") | ||||
|         if conn: | ||||
|             conn.rollback() | ||||
|         return False | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error adding email {email}: {e}") | ||||
|     except Exception: | ||||
|         if conn: | ||||
|             conn.rollback() | ||||
|         return False | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def remove_email(email): | ||||
|     """Remove an email from the subscribers table.""" | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|         cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,)) | ||||
|         rowcount = cursor.rowcount | ||||
|         conn.commit() | ||||
|         logger.info(f"Email {email} removed successfully.") | ||||
|         return rowcount > 0 | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error removing email {email}: {e}") | ||||
|     except Exception: | ||||
|         if conn: | ||||
|             conn.rollback() | ||||
|         return False | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def get_admin(username): | ||||
|  | @ -151,6 +152,7 @@ def get_admin(username): | |||
|     Returns a tuple (username, password_hash) if found, otherwise None. | ||||
|     """ | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
|  | @ -158,15 +160,14 @@ def get_admin(username): | |||
|             "SELECT username, password FROM admin_users WHERE username = %s", | ||||
|             (username,), | ||||
|         ) | ||||
|         result = cursor.fetchone() | ||||
|         return result  # (username, password_hash) | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error retrieving admin: {e}") | ||||
|         return cursor.fetchone() | ||||
|     except Exception: | ||||
|         return None | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def create_default_admin(): | ||||
|  | @ -174,30 +175,60 @@ def create_default_admin(): | |||
|     default_username = os.getenv("ADMIN_USERNAME", "admin") | ||||
|     default_password = os.getenv("ADMIN_PASSWORD", "changeme") | ||||
|     hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256") | ||||
| 
 | ||||
|     conn = None | ||||
|     cursor = None | ||||
|     try: | ||||
|         conn = get_connection() | ||||
|         cursor = conn.cursor() | ||||
| 
 | ||||
|         # Check if the admin already exists | ||||
|         cursor.execute( | ||||
|             "SELECT id FROM admin_users WHERE username = %s", (default_username,) | ||||
|         ) | ||||
|         if cursor.fetchone() is None: | ||||
|         exists = cursor.fetchone() | ||||
|         if exists is None: | ||||
|             cursor.execute( | ||||
|                 "INSERT INTO admin_users (username, password) VALUES (%s, %s)", | ||||
|                 (default_username, hashed_password), | ||||
|             ) | ||||
|             conn.commit() | ||||
|             logger.info("Default admin created successfully") | ||||
|         else: | ||||
|             logger.info("Default admin already exists") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error creating default admin: {e}") | ||||
|     except Exception: | ||||
|         if conn: | ||||
|             conn.rollback() | ||||
|     finally: | ||||
|         if conn: | ||||
|         if cursor: | ||||
|             cursor.close() | ||||
|             conn.close() | ||||
|         if conn: | ||||
|             conn_pool.putconn(conn) | ||||
| 
 | ||||
| 
 | ||||
| def close_pool(): | ||||
|     """Close the database connection pool.""" | ||||
|     try: | ||||
|         conn_pool.closeall() | ||||
|     except Exception: | ||||
|         pass | ||||
| 
 | ||||
| 
 | ||||
| class DatabaseContext: | ||||
|     """Optional context manager for manual transactions.""" | ||||
|     def __init__(self): | ||||
|         self.conn = None | ||||
|         self.cursor = None | ||||
| 
 | ||||
|     def __enter__(self): | ||||
|         self.conn = get_connection() | ||||
|         self.cursor = self.conn.cursor() | ||||
|         return self.cursor | ||||
| 
 | ||||
|     def __exit__(self, exc_type, exc_val, exc_tb): | ||||
|         try: | ||||
|             if exc_type: | ||||
|                 self.conn.rollback() | ||||
|             else: | ||||
|                 self.conn.commit() | ||||
|         finally: | ||||
|             if self.cursor: | ||||
|                 self.cursor.close() | ||||
|             if self.conn: | ||||
|                 conn_pool.putconn(self.conn) | ||||
|  | @ -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; | ||||
|     padding: 20px; | ||||
|   font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif; | ||||
|   color: var(--text); | ||||
|   background: radial-gradient(1000px 650px at 0% 0%, var(--bg-grad-1), transparent 60%), | ||||
|               radial-gradient(900px 600px at 100% 0%, var(--bg-grad-2), transparent 55%), | ||||
|               var(--bg); | ||||
|   min-height: 100vh; | ||||
| } | ||||
| 
 | ||||
| .navbar { | ||||
|   position: sticky; | ||||
|   top: 0; | ||||
|   z-index: 1000; | ||||
|   background: rgba(255, 255, 255, 0.8); | ||||
|   backdrop-filter: blur(10px); | ||||
|   border-bottom: 1px solid rgba(15, 23, 42, 0.06); | ||||
| } | ||||
| .navbar-content { | ||||
|   max-width: 1100px; | ||||
|   margin: 0 auto; | ||||
|   padding: 14px 20px; | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|   justify-content: space-between; | ||||
| } | ||||
| .brand { | ||||
|   color: var(--text); | ||||
|   font-weight: 700; | ||||
|   text-decoration: none; | ||||
|   letter-spacing: 0.2px; | ||||
| } | ||||
| .navbar-links a { | ||||
|   color: var(--muted); | ||||
|   text-decoration: none; | ||||
|   margin-left: 14px; | ||||
|   padding: 8px 12px; | ||||
|   border-radius: 10px; | ||||
|   transition: all 0.2s ease; | ||||
| } | ||||
| .navbar-links a:hover { | ||||
|   color: var(--text); | ||||
|   background: rgba(15, 23, 42, 0.05); | ||||
| } | ||||
| .navbar-links .logout { | ||||
|   color: #b91c1c; | ||||
| } | ||||
| .navbar-links .logout:hover { | ||||
|   background: rgba(185, 28, 28, 0.08); | ||||
| } | ||||
| 
 | ||||
| .container { | ||||
|   max-width: 1100px; | ||||
|   margin: 28px auto 48px; | ||||
|   padding: 0 20px; | ||||
|   padding-bottom: 64px; | ||||
| } | ||||
| 
 | ||||
| .footer { | ||||
|   position: fixed; | ||||
|   left: 0; | ||||
|   right: 0; | ||||
|   bottom: 0; | ||||
|   z-index: 1000; | ||||
|   border-top: 1px solid rgba(15, 23, 42, 0.06); | ||||
|   background: rgba(255, 255, 255, 0.92); | ||||
|   backdrop-filter: blur(8px); | ||||
| } | ||||
| 
 | ||||
| .footer-inner { | ||||
|   max-width: 1100px; | ||||
|   margin: 0 auto; | ||||
|   padding: 14px 20px; | ||||
|   color: var(--muted); | ||||
|   font-size: 14px; | ||||
| } | ||||
| 
 | ||||
| .page-header { | ||||
|   margin-bottom: 18px; | ||||
|   display: flex; | ||||
|   align-items: end; | ||||
|   justify-content: space-between; | ||||
|   gap: 12px; | ||||
|   flex-wrap: wrap; | ||||
| } | ||||
| .page-title { | ||||
|   margin: 0 0 4px 0; | ||||
|   font-size: 26px; | ||||
|   font-weight: 700; | ||||
| } | ||||
| .page-subtitle { | ||||
|   margin: 0; | ||||
|   color: var(--muted); | ||||
|   font-size: 14px; | ||||
| } | ||||
| .page-actions { | ||||
|   display: flex; | ||||
|   gap: 10px; | ||||
| } | ||||
| 
 | ||||
| .card { | ||||
|   background: var(--surface); | ||||
|   border: 1px solid var(--border); | ||||
|   border-radius: var(--radius); | ||||
|   box-shadow: var(--shadow-1); | ||||
|   padding: 20px; | ||||
| } | ||||
| .empty-state { | ||||
|   text-align: center; | ||||
|   color: var(--muted); | ||||
| } | ||||
| 
 | ||||
| .widgets { | ||||
|   display: grid; | ||||
|   grid-template-columns: repeat(3, minmax(0, 1fr)); | ||||
|   gap: 16px; | ||||
|   margin-bottom: 18px; | ||||
| } | ||||
| .widget-card { | ||||
|   background: linear-gradient(180deg, #fff, #fafcff); | ||||
|   border: 1px solid var(--border); | ||||
|   border-radius: var(--radius); | ||||
|   box-shadow: var(--shadow-1); | ||||
|   padding: 18px; | ||||
| } | ||||
| .widget-label { | ||||
|   color: var(--muted); | ||||
|   font-weight: 600; | ||||
|   font-size: 13px; | ||||
|   margin-bottom: 8px; | ||||
| } | ||||
| .widget-value { | ||||
|   font-size: 28px; | ||||
|   font-weight: 800; | ||||
|   letter-spacing: 0.3px; | ||||
|   color: var(--text); | ||||
| } | ||||
| 
 | ||||
| .table-wrap { | ||||
|   overflow: hidden; | ||||
|   border-radius: 12px; | ||||
|   border: 1px solid var(--border); | ||||
| } | ||||
| .table { | ||||
|   width: 100%; | ||||
|   border-collapse: collapse; | ||||
|   background: transparent; | ||||
| } | ||||
| .table thead th { | ||||
|   text-align: left; | ||||
|   font-weight: 600; | ||||
|   color: var(--muted); | ||||
|   font-size: 13px; | ||||
|   letter-spacing: 0.3px; | ||||
|   background: #f9fafb; | ||||
|   padding: 12px 14px; | ||||
|   border-bottom: 1px solid var(--border); | ||||
| } | ||||
| .table tbody td { | ||||
|   padding: 14px; | ||||
|   border-bottom: 1px solid #f1f5f9; | ||||
| } | ||||
| .table tbody tr:hover td { | ||||
|   background: #f9fbff; | ||||
|   transition: background 0.15s ease; | ||||
| } | ||||
| 
 | ||||
| .form { | ||||
|   display: grid; | ||||
|   gap: 16px; | ||||
| } | ||||
| .form-group { | ||||
|   display: grid; | ||||
|   gap: 8px; | ||||
| } | ||||
| .form-group label { | ||||
|   font-weight: 600; | ||||
|   color: #334155; | ||||
|   font-size: 14px; | ||||
| } | ||||
| .form-group input, | ||||
| .form-group textarea { | ||||
|   width: 100%; | ||||
|   color: var(--text); | ||||
|   background: #ffffff; | ||||
|   border: 1px solid var(--border); | ||||
|   border-radius: 12px; | ||||
|   padding: 12px 14px; | ||||
|   font-size: 15px; | ||||
|   transition: box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease; | ||||
| } | ||||
| .form-group textarea { | ||||
|   resize: vertical; | ||||
|   min-height: 160px; | ||||
| } | ||||
| .form-group input:focus, | ||||
| .form-group textarea:focus { | ||||
|   outline: none; | ||||
|   border-color: #a7c2ff; | ||||
|   box-shadow: 0 0 0 4px var(--ring); | ||||
|   background: #ffffff; | ||||
| } | ||||
| .form-actions { | ||||
|   display: flex; | ||||
|   gap: 10px; | ||||
|   justify-content: flex-end; | ||||
| } | ||||
| 
 | ||||
| .button { | ||||
|   appearance: none; | ||||
|   border: 1px solid var(--border); | ||||
|   background: #ffffff; | ||||
|   color: var(--text); | ||||
|   border-radius: 12px; | ||||
|   padding: 10px 14px; | ||||
|   cursor: pointer; | ||||
|   font-weight: 600; | ||||
|   transition: transform 0.05s ease, background 0.2s ease, border 0.2s ease; | ||||
| } | ||||
| .button:hover { | ||||
|   background: #f5f7fb; | ||||
| } | ||||
| .button:active { | ||||
|   transform: translateY(1px); | ||||
| } | ||||
| .button-primary { | ||||
|   color: #ffffff; | ||||
|   background: linear-gradient(180deg, #3b82f6, var(--primary)); | ||||
|   border-color: rgba(37, 99, 235, 0.4); | ||||
| } | ||||
| .button-primary:hover { | ||||
|   background: linear-gradient(180deg, #2f74ed, var(--primary-hover)); | ||||
| } | ||||
| .button-secondary { | ||||
|   background: #ffffff; | ||||
| } | ||||
| 
 | ||||
| .flash-stack { | ||||
|   display: grid; | ||||
|   gap: 10px; | ||||
|   margin-bottom: 18px; | ||||
| } | ||||
| .flash { | ||||
|   border-radius: 12px; | ||||
|   padding: 12px 14px; | ||||
|   font-weight: 600; | ||||
|   border: 1px solid var(--border); | ||||
|   background: #ffffff; | ||||
| } | ||||
| .flash-success { | ||||
|   border-color: #a7f3d0; | ||||
|   background: #ecfdf5; | ||||
|   color: #065f46; | ||||
| } | ||||
| .flash-danger, | ||||
| .flash-error { | ||||
|   border-color: #fecaca; | ||||
|   background: #fef2f2; | ||||
|   color: #991b1b; | ||||
| } | ||||
| .flash-warning { | ||||
|   border-color: #fde68a; | ||||
|   background: #fffbeb; | ||||
|   color: #92400e; | ||||
| } | ||||
| 
 | ||||
| .auth-wrapper { | ||||
|   display: grid; | ||||
|   place-items: center; | ||||
|   min-height: calc(100vh - 120px); | ||||
|   padding-top: 40px; | ||||
| } | ||||
| .auth-card { | ||||
|   max-width: 420px; | ||||
|   width: 100%; | ||||
| } | ||||
| 
 | ||||
| @media (max-width: 900px) { | ||||
|   .widgets { | ||||
|     grid-template-columns: 1fr; | ||||
|   } | ||||
|    | ||||
|   table { | ||||
|     border-collapse: collapse; | ||||
|     width: 100%; | ||||
|   } | ||||
|    | ||||
|   th, | ||||
|   td { | ||||
|     border: 1px solid #ddd; | ||||
|     padding: 8px; | ||||
|     text-align: left; | ||||
|   } | ||||
|    | ||||
|   th { | ||||
|     background-color: #f2f2f2; | ||||
|   } | ||||
|    | ||||
|   a { | ||||
|     margin-right: 10px; | ||||
|   } | ||||
|    | ||||
|   form { | ||||
|     max-width: 600px; | ||||
|     margin: 0 auto; | ||||
|   } | ||||
|    | ||||
|   label { | ||||
|     display: block; | ||||
|     margin-top: 15px; | ||||
|   } | ||||
|    | ||||
|   input[type="text"], | ||||
|   input[type="password"], | ||||
|   textarea { | ||||
|     width: 100%; | ||||
|     padding: 8px; | ||||
|   } | ||||
|    | ||||
|   button { | ||||
|     margin-top: 15px; | ||||
|     padding: 10px 20px; | ||||
|   } | ||||
|    | ||||
|   .flash { | ||||
|     background-color: #f8d7da; | ||||
|     color: #721c24; | ||||
|     padding: 10px; | ||||
|     margin-bottom: 10px; | ||||
|     text-align: center; | ||||
|   } | ||||
|    | ||||
| } | ||||
|  | @ -1,44 +1,53 @@ | |||
| <!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> | ||||
|   <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> | ||||
| 
 | ||||
|     {% with messages = get_flashed_messages(with_categories=true) %} | ||||
|       {% if messages %} | ||||
|         {% for category, message in messages %} | ||||
|           <div class="flash">{{ message }}</div> | ||||
|         {% endfor %} | ||||
|       {% endif %} | ||||
|     {% endwith %} | ||||
| 
 | ||||
|     {% if emails %} | ||||
|         <table> | ||||
|             <thead> | ||||
|                 <tr> | ||||
|                     <th>Email Address</th> | ||||
|                 </tr> | ||||
|             </thead> | ||||
|             <tbody> | ||||
|                 {% for email in emails %} | ||||
|                     <tr> | ||||
|                         <td>{{ email }}</td> | ||||
|                     </tr> | ||||
|                 {% endfor %} | ||||
|             </tbody> | ||||
|   {% if emails %} | ||||
|     <div class="card"> | ||||
|       <div class="table-wrap"> | ||||
|         <table class="table"> | ||||
|           <thead> | ||||
|             <tr> | ||||
|               <th>Email Address</th> | ||||
|             </tr> | ||||
|           </thead> | ||||
|           <tbody> | ||||
|             {% for email in emails %} | ||||
|               <tr> | ||||
|                 <td>{{ email }}</td> | ||||
|               </tr> | ||||
|             {% endfor %} | ||||
|           </tbody> | ||||
|         </table> | ||||
|     {% else %} | ||||
|         <p>No subscribers found.</p> | ||||
|     {% endif %} | ||||
| </body> | ||||
| </html> | ||||
|       </div> | ||||
|     </div> | ||||
|   {% else %} | ||||
|     <div class="card empty-state"> | ||||
|       <p>No subscribers found.</p> | ||||
|     </div> | ||||
|   {% endif %} | ||||
| {% endblock %} | ||||
							
								
								
									
										58
									
								
								templates/base.html
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										58
									
								
								templates/base.html
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,58 @@ | |||
| <!DOCTYPE html> | ||||
| <html lang="en"> | ||||
| <head> | ||||
|   <meta charset="UTF-8" /> | ||||
|   <meta | ||||
|     name="viewport" | ||||
|     content="width=device-width, initial-scale=1.0, viewport-fit=cover" | ||||
|   /> | ||||
|   <title>{% block title %}Admin{% endblock %}</title> | ||||
|   <link | ||||
|     rel="stylesheet" | ||||
|     href="{{ url_for('static', filename='css/style.css') }}" | ||||
|   /> | ||||
|   <link | ||||
|     rel="preconnect" | ||||
|     href="https://fonts.googleapis.com" | ||||
|     crossorigin | ||||
|   /> | ||||
|   <link | ||||
|     href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" | ||||
|     rel="stylesheet" | ||||
|   /> | ||||
| </head> | ||||
| <body> | ||||
|   <header class="navbar"> | ||||
|     <div class="navbar-content"> | ||||
|       <a class="brand" href="{{ url_for('index') }}">Admin Panel</a> | ||||
|       <nav class="navbar-links"> | ||||
|         {% if session.get('username') %} | ||||
|         <a href="{{ url_for('index') }}">Dashboard</a> | ||||
|         <a href="{{ url_for('send_update') }}">Send Update</a> | ||||
|         <a href="{{ url_for('logout') }}" class="logout">Logout</a> | ||||
|         {% endif %} | ||||
|       </nav> | ||||
|     </div> | ||||
|   </header> | ||||
| 
 | ||||
|   <main class="container"> | ||||
|     {% with messages = get_flashed_messages(with_categories=true) %} | ||||
|       {% if messages %} | ||||
|         <div class="flash-stack"> | ||||
|           {% for category, message in messages %} | ||||
|             <div class="flash flash-{{ category|lower }}">{{ message }}</div> | ||||
|           {% endfor %} | ||||
|         </div> | ||||
|       {% endif %} | ||||
|     {% endwith %} | ||||
| 
 | ||||
|     {% block content %}{% endblock %} | ||||
|   </main> | ||||
| 
 | ||||
|   <footer class="footer"> | ||||
|     <div class="footer-inner"> | ||||
|       <span>© {{ 2025 }} Admin Panel</span> | ||||
|     </div> | ||||
|   </footer> | ||||
| </body> | ||||
| </html> | ||||
|  | @ -1,29 +1,34 @@ | |||
| <!DOCTYPE html> | ||||
| <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> | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
|       <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> | ||||
|     </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> | ||||
|   </form> | ||||
| 
 | ||||
| </body> | ||||
| 
 | ||||
| </html> | ||||
|       <div class="form-actions"> | ||||
|         <button type="submit" class="button button-primary">Send Update</button> | ||||
|       </div> | ||||
|     </form> | ||||
|   </div> | ||||
| {% endblock %} | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue