Compare commits
	
		
			No commits in common. "main" and "go-rewrite" have entirely different histories.
		
	
	
		
			main
			...
			go-rewrite
		
	
		
					 15 changed files with 382 additions and 838 deletions
				
			
		
							
								
								
									
										8
									
								
								.idea/.gitignore
									
										
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.idea/.gitignore
									
										
									
										generated
									
									
										vendored
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,8 @@
 | 
				
			||||||
 | 
					# 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
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								.idea/admin-panel.iml
									
										
									
										generated
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,21 @@
 | 
				
			||||||
 | 
					<?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
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.idea/inspectionProfiles/profiles_settings.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,6 @@
 | 
				
			||||||
 | 
					<component name="InspectionProjectProfileManager">
 | 
				
			||||||
 | 
					  <settings>
 | 
				
			||||||
 | 
					    <option name="USE_PROJECT_PROFILE" value="false" />
 | 
				
			||||||
 | 
					    <version value="1.0" />
 | 
				
			||||||
 | 
					  </settings>
 | 
				
			||||||
 | 
					</component>
 | 
				
			||||||
							
								
								
									
										6
									
								
								.idea/misc.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								.idea/misc.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,6 @@
 | 
				
			||||||
 | 
					<?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
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								.idea/modules.xml
									
										
									
										generated
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,8 @@
 | 
				
			||||||
 | 
					<?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,30 +1,18 @@
 | 
				
			||||||
# Use an official Python runtime as a base
 | 
					FROM python:3.11-slim-buster
 | 
				
			||||||
FROM python:3.11-slim-bookworm
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Set working directory
 | 
					# Install build dependencies (build-essential provides gcc and other tools)
 | 
				
			||||||
WORKDIR /app
 | 
					RUN apt-get update && apt-get install -y build-essential
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Install system dependencies
 | 
					WORKDIR /rideaware_landing
 | 
				
			||||||
RUN apt-get update && apt-get install -y \
 | 
					 | 
				
			||||||
    build-essential \
 | 
					 | 
				
			||||||
    libpq-dev \
 | 
					 | 
				
			||||||
    && rm -rf /var/lib/apt/lists/*
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Copy requirements first to leverage Docker cache
 | 
					 | 
				
			||||||
COPY requirements.txt .
 | 
					COPY requirements.txt .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Install Python dependencies
 | 
					 | 
				
			||||||
RUN pip install --no-cache-dir -r requirements.txt
 | 
					RUN pip install --no-cache-dir -r requirements.txt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Copy application code
 | 
					 | 
				
			||||||
COPY . .
 | 
					COPY . .
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Environment variables
 | 
					ENV FLASK_APP=server.py
 | 
				
			||||||
ENV FLASK_APP=app.py
 | 
					 | 
				
			||||||
ENV FLASK_ENV=production
 | 
					 | 
				
			||||||
ENV ENVIRONMENT=production
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
EXPOSE 5001
 | 
					EXPOSE 5001
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Use Gunicorn as production server
 | 
					CMD ["gunicorn", "--bind", "0.0.0.0:5001", "app:app"]
 | 
				
			||||||
CMD ["gunicorn", "--bind", "0.0.0.0:5001", "--workers", "4", "--timeout", "120", "app:app"]
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										113
									
								
								README.md
									
										
									
									
									
								
							
							
						
						
									
										113
									
								
								README.md
									
										
									
									
									
								
							| 
						 | 
					@ -1,87 +1,57 @@
 | 
				
			||||||
# RideAware Admin Center
 | 
					# RideAware Admin Center
 | 
				
			||||||
 | 
					
 | 
				
			||||||
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.
 | 
					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.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Features
 | 
					## Features
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- **Admin Login:**  
 | 
				
			||||||
 | 
					  Secure login using salted and hashed passwords (via Werkzeug security utilities).
 | 
				
			||||||
 | 
					
 | 
				
			||||||
**Secure Admin Authentication:**
 | 
					- **Subscriber List:**  
 | 
				
			||||||
 | 
					  View all email addresses currently stored in the `subscribers` table.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
* Login protected by username/password authentication using Werkzeug's password hashing.
 | 
					- **Email Updates:**  
 | 
				
			||||||
* Default admin credentials configurable via environment variables.
 | 
					  A form for sending update emails (HTML allowed) to the subscriber list using SMTP.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
**Subscriber Management:**
 | 
					- **Shared Database:**  
 | 
				
			||||||
* View a comprehensive list of all subscribed email addresses.
 | 
					  Both the landing page app (port 5000) and Admin Center (port 5001) connect to the same `subscribers.db`.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
**Email Marketing:**
 | 
					## Setup & Running
 | 
				
			||||||
* 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
 | 
					### Prerequisites
 | 
				
			||||||
 | 
					
 | 
				
			||||||
*   Docker (recommended for containerized deployment)
 | 
					- Docker (for containerized deployment)
 | 
				
			||||||
*   Python 3.11+ (if running locally without Docker)
 | 
					- Python 3.11+ (if running locally without Docker)
 | 
				
			||||||
*   A PostgreSQL database instance
 | 
					- An SMTP account (e.g., Spacemail) for sending emails
 | 
				
			||||||
*   An SMTP account (e.g., SendGrid, Mailgun) for sending emails
 | 
					- A `.env` file with configuration details
 | 
				
			||||||
*   A `.env` file with configuration details
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
### .env Configuration
 | 
					### .env Configuration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
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.
 | 
					Create a `.env` file in the project root with the following example variables:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
```env
 | 
					```env
 | 
				
			||||||
# Flask Application
 | 
					# SMTP settings (shared with the landing page app)
 | 
				
			||||||
SECRET_KEY="YourSecretKeyHere" #Used to sign session cookies
 | 
					SMTP_SERVER=<email server>
 | 
				
			||||||
 | 
					SMTP_PORT=<email port>
 | 
				
			||||||
 | 
					SMTP_USER=<email username>
 | 
				
			||||||
 | 
					SMTP_PASSWORD=<email password>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# PostgreSQL Database Configuration
 | 
					# Database file
 | 
				
			||||||
PG_HOST=localhost
 | 
					DATABASE_FILE=subscribers.db
 | 
				
			||||||
PG_PORT=5432
 | 
					 | 
				
			||||||
PG_DATABASE=rideaware_db
 | 
					 | 
				
			||||||
PG_USER=rideaware_user
 | 
					 | 
				
			||||||
PG_PASSWORD=rideaware_password
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Admin credentials for the Admin Center
 | 
					# Admin credentials for the Admin Center
 | 
				
			||||||
ADMIN_USERNAME=admin
 | 
					ADMIN_USERNAME=admin
 | 
				
			||||||
ADMIN_PASSWORD="changeme"  # Change this to a secure password
 | 
					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 (Recommended)
 | 
					### Running with Docker
 | 
				
			||||||
 | 
					 | 
				
			||||||
This is the recommended approach for deploying the RideAware Admin Panel
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
Building the Docker image:
 | 
					Building the Docker image:
 | 
				
			||||||
```sh
 | 
					```sh
 | 
				
			||||||
| 
						 | 
					@ -93,35 +63,18 @@ Running the container mapping port 5001:
 | 
				
			||||||
docker run -p 5001:5001 admin-panel
 | 
					docker run -p 5001:5001 admin-panel
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
The application will be accessible at `http://localhost:5001` or `http://<your_server_ip>:5001`
 | 
					The app will be accessible at http://ip-address-here: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
 | 
					### Running locally
 | 
				
			||||||
 | 
					Install the dependencies using **requirements.txt**:
 | 
				
			||||||
Install Dependencies:
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
```sh
 | 
					```sh
 | 
				
			||||||
pip install -r requirements.txt
 | 
					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:
 | 
					Then run the admin app:
 | 
				
			||||||
```sh
 | 
					```sh
 | 
				
			||||||
python app.py
 | 
					python app.py
 | 
				
			||||||
```
 | 
					```
 | 
				
			||||||
 | 
					
 | 
				
			||||||
The app will be accessible at `http://127.0.0.1:5001`
 | 
					The app will be accessible at http://ip-address-here: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.
 | 
					 | 
				
			||||||
							
								
								
									
										198
									
								
								app.py
									
										
									
									
									
								
							
							
						
						
									
										198
									
								
								app.py
									
										
									
									
									
								
							| 
						 | 
					@ -1,9 +1,7 @@
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
import smtplib
 | 
					import smtplib
 | 
				
			||||||
from email.mime.text import MIMEText
 | 
					from email.mime.text import MIMEText
 | 
				
			||||||
from functools import wraps
 | 
					 | 
				
			||||||
from urllib.parse import urlparse, urljoin
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from flask import (
 | 
					from flask import (
 | 
				
			||||||
    Flask,
 | 
					    Flask,
 | 
				
			||||||
    render_template,
 | 
					    render_template,
 | 
				
			||||||
| 
						 | 
					@ -13,222 +11,135 @@ from flask import (
 | 
				
			||||||
    flash,
 | 
					    flash,
 | 
				
			||||||
    session,
 | 
					    session,
 | 
				
			||||||
)
 | 
					)
 | 
				
			||||||
from markupsafe import escape
 | 
					 | 
				
			||||||
from dotenv import load_dotenv
 | 
					from dotenv import load_dotenv
 | 
				
			||||||
from werkzeug.security import check_password_hash
 | 
					from werkzeug.security import check_password_hash
 | 
				
			||||||
 | 
					from functools import wraps  # Import wraps
 | 
				
			||||||
from database import (
 | 
					from database import get_connection, init_db, get_all_emails, get_admin, create_default_admin
 | 
				
			||||||
    get_connection,
 | 
					 | 
				
			||||||
    init_db,
 | 
					 | 
				
			||||||
    get_all_emails,
 | 
					 | 
				
			||||||
    get_admin,
 | 
					 | 
				
			||||||
    create_default_admin,
 | 
					 | 
				
			||||||
)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
load_dotenv()
 | 
					load_dotenv()
 | 
				
			||||||
 | 
					 | 
				
			||||||
app = Flask(__name__)
 | 
					app = Flask(__name__)
 | 
				
			||||||
app.secret_key = os.getenv("SECRET_KEY")
 | 
					app.secret_key = os.getenv("SECRET_KEY")
 | 
				
			||||||
base_url = os.getenv("BASE_URL", "").strip().strip("/")
 | 
					base_url = os.getenv("BASE_URL")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# SMTP settings (for sending update emails)
 | 
				
			||||||
SMTP_SERVER = os.getenv("SMTP_SERVER")
 | 
					SMTP_SERVER = os.getenv("SMTP_SERVER")
 | 
				
			||||||
SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
 | 
					SMTP_PORT = int(os.getenv("SMTP_PORT", 465))
 | 
				
			||||||
SMTP_USER = os.getenv("SMTP_USER")
 | 
					SMTP_USER = os.getenv("SMTP_USER")
 | 
				
			||||||
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
 | 
					SMTP_PASSWORD = os.getenv("SMTP_PASSWORD")
 | 
				
			||||||
SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER)
 | 
					SENDER_EMAIL = os.getenv("SENDER_EMAIL", SMTP_USER) # Use SENDER_EMAIL
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Logging setup
 | 
				
			||||||
 | 
					logging.basicConfig(
 | 
				
			||||||
 | 
					    level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					logger = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Initialize the database and create default admin user if necessary.
 | 
				
			||||||
init_db()
 | 
					init_db()
 | 
				
			||||||
create_default_admin()
 | 
					create_default_admin()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Decorator for requiring login
 | 
				
			||||||
def login_required(f):
 | 
					def login_required(f):
 | 
				
			||||||
    @wraps(f)
 | 
					    @wraps(f)  # Use wraps to preserve function metadata
 | 
				
			||||||
    def decorated_function(*args, **kwargs):
 | 
					    def decorated_function(*args, **kwargs):
 | 
				
			||||||
        if "username" not in session:
 | 
					        if "username" not in session:
 | 
				
			||||||
            next_url = request.full_path if request.query_string else request.path
 | 
					            return redirect(url_for("login"))
 | 
				
			||||||
            return redirect(url_for("login", next=next_url))
 | 
					 | 
				
			||||||
        return f(*args, **kwargs)
 | 
					        return f(*args, **kwargs)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return decorated_function
 | 
					    return decorated_function
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_dashboard_counts():
 | 
					def send_update_email(subject, body, email):
 | 
				
			||||||
    """Return dict of counts: total subscribers, total newsletters, sent today."""
 | 
					    """Sends email, returns True on success, False on failure."""
 | 
				
			||||||
    counts = {"total_subscribers": 0, "total_newsletters": 0, "sent_today": 0}
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        conn = get_connection()
 | 
					 | 
				
			||||||
        cur = conn.cursor()
 | 
					 | 
				
			||||||
        cur.execute("SELECT COUNT(*) FROM subscribers")
 | 
					 | 
				
			||||||
        counts["total_subscribers"] = cur.fetchone()[0] or 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        cur.execute("SELECT COUNT(*) FROM newsletters")
 | 
					 | 
				
			||||||
        counts["total_newsletters"] = cur.fetchone()[0] or 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        cur.execute(
 | 
					 | 
				
			||||||
            """
 | 
					 | 
				
			||||||
            SELECT COUNT(*)
 | 
					 | 
				
			||||||
            FROM newsletters
 | 
					 | 
				
			||||||
            WHERE sent_at::date = CURRENT_DATE
 | 
					 | 
				
			||||||
            """
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
        counts["sent_today"] = cur.fetchone()[0] or 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        cur.close()
 | 
					 | 
				
			||||||
        conn.close()
 | 
					 | 
				
			||||||
    except Exception:
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
    return counts
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def is_safe_url(target: str) -> bool:
 | 
					 | 
				
			||||||
    if not target:
 | 
					 | 
				
			||||||
        return False
 | 
					 | 
				
			||||||
    ref = urlparse(request.host_url)
 | 
					 | 
				
			||||||
    test = urlparse(urljoin(request.host_url, target))
 | 
					 | 
				
			||||||
    return test.scheme in ("http", "https") and ref.netloc == test.netloc
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def send_update_email(subject: str, body_html: str, email: str) -> bool:
 | 
					 | 
				
			||||||
    """Send a single HTML email with retries."""
 | 
					 | 
				
			||||||
    max_retries = 3
 | 
					 | 
				
			||||||
    retry_count = 0
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    unsub_link = ""
 | 
					 | 
				
			||||||
    if base_url:
 | 
					 | 
				
			||||||
        unsub_link = f"https://{base_url}/unsubscribe?email={email}"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if unsub_link:
 | 
					 | 
				
			||||||
        custom_body = (
 | 
					 | 
				
			||||||
            f"{body_html}"
 | 
					 | 
				
			||||||
            f"<br><br>"
 | 
					 | 
				
			||||||
            f"If you ever wish to unsubscribe, please click "
 | 
					 | 
				
			||||||
            f"<a href='{unsub_link}'>here</a>."
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        custom_body = body_html
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    while retry_count < max_retries:
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
 | 
					        server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
 | 
				
			||||||
            server.set_debuglevel(0)
 | 
					        server.set_debuglevel(False)  # Keep debug level at False for production
 | 
				
			||||||
        server.login(SMTP_USER, SMTP_PASSWORD)
 | 
					        server.login(SMTP_USER, SMTP_PASSWORD)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        unsub_link = f"https://{base_url}/unsubscribe?email={email}"
 | 
				
			||||||
 | 
					        custom_body = (
 | 
				
			||||||
 | 
					            f"{body}<br><br>"
 | 
				
			||||||
 | 
					            f"If you ever wish to unsubscribe, please click <a href='{unsub_link}'>here</a>"
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        msg = MIMEText(custom_body, "html", "utf-8")
 | 
					        msg = MIMEText(custom_body, "html", "utf-8")
 | 
				
			||||||
        msg["Subject"] = subject
 | 
					        msg["Subject"] = subject
 | 
				
			||||||
            msg["From"] = SENDER_EMAIL
 | 
					        msg["From"] = SENDER_EMAIL  # Use sender email
 | 
				
			||||||
        msg["To"] = email
 | 
					        msg["To"] = email
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            server.sendmail(SENDER_EMAIL, [email], msg.as_string())
 | 
					        server.sendmail(SENDER_EMAIL, email, msg.as_string())  # Use sender email
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        server.quit()
 | 
					        server.quit()
 | 
				
			||||||
 | 
					        logger.info(f"Update email sent to: {email}")
 | 
				
			||||||
        return True
 | 
					        return True
 | 
				
			||||||
        except Exception:
 | 
					    except Exception as e:
 | 
				
			||||||
            retry_count += 1
 | 
					        logger.error(f"Failed to send email to {email}: {e}")
 | 
				
			||||||
            if retry_count >= max_retries:
 | 
					 | 
				
			||||||
                break
 | 
					 | 
				
			||||||
            import time
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            time.sleep(1.0)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return False
 | 
					        return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def process_send_update_email(subject: str, body_html: str) -> str:
 | 
					def process_send_update_email(subject, body):
 | 
				
			||||||
    """Send update email to all subscribers and log newsletter content."""
 | 
					    """Helper function to send an update email to all subscribers."""
 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
    subscribers = get_all_emails()
 | 
					    subscribers = get_all_emails()
 | 
				
			||||||
    if not subscribers:
 | 
					    if not subscribers:
 | 
				
			||||||
        return "No subscribers found."
 | 
					        return "No subscribers found."
 | 
				
			||||||
 | 
					 | 
				
			||||||
        failures = []
 | 
					 | 
				
			||||||
        for email in subscribers:
 | 
					 | 
				
			||||||
            if not send_update_email(subject, body_html, email):
 | 
					 | 
				
			||||||
                failures.append(email)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
 | 
					        for email in subscribers:
 | 
				
			||||||
 | 
					            if not send_update_email(subject, body, email):
 | 
				
			||||||
 | 
					                return f"Failed to send to {email}"  # Specific failure message
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Log newsletter content for audit purposes
 | 
				
			||||||
        conn = get_connection()
 | 
					        conn = get_connection()
 | 
				
			||||||
        cursor = conn.cursor()
 | 
					        cursor = conn.cursor()
 | 
				
			||||||
        cursor.execute(
 | 
					        cursor.execute(
 | 
				
			||||||
                "INSERT INTO newsletters (subject, body) VALUES (%s, %s)",
 | 
					            "INSERT INTO newsletters (subject, body) VALUES (%s, %s)", (subject, body)
 | 
				
			||||||
                (subject, body_html),
 | 
					 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        conn.commit()
 | 
					        conn.commit()
 | 
				
			||||||
        cursor.close()
 | 
					        cursor.close()
 | 
				
			||||||
        conn.close()
 | 
					        conn.close()
 | 
				
			||||||
        except Exception:
 | 
					 | 
				
			||||||
            pass
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if failures:
 | 
					 | 
				
			||||||
            return f"Sent with failures: {len(failures)} recipients failed."
 | 
					 | 
				
			||||||
        return "Email has been sent to all subscribers."
 | 
					        return "Email has been sent to all subscribers."
 | 
				
			||||||
    except Exception as e:
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logger.exception("Error processing sending updates")
 | 
				
			||||||
        return f"Failed to send email: {e}"
 | 
					        return f"Failed to send email: {e}"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route("/", methods=["GET"])
 | 
					@app.route("/")
 | 
				
			||||||
@login_required
 | 
					@login_required
 | 
				
			||||||
def index():
 | 
					def index():
 | 
				
			||||||
    """Dashboard: list subscriber emails and show widgets."""
 | 
					    """Displays all subscriber emails"""
 | 
				
			||||||
    emails = []
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
    emails = get_all_emails()
 | 
					    emails = get_all_emails()
 | 
				
			||||||
    except Exception:
 | 
					    return render_template("admin_index.html", emails=emails)
 | 
				
			||||||
        flash("Could not load subscribers right now.", "danger")
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    counts = get_dashboard_counts()
 | 
					 | 
				
			||||||
    return render_template("admin_index.html", emails=emails, counts=counts)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route("/send_update", methods=["GET", "POST"])
 | 
					@app.route("/send_update", methods=["GET", "POST"])
 | 
				
			||||||
@login_required
 | 
					@login_required
 | 
				
			||||||
def send_update():
 | 
					def send_update():
 | 
				
			||||||
 | 
					    """Display a form to send an update email; process submission on POST."""
 | 
				
			||||||
    if request.method == "POST":
 | 
					    if request.method == "POST":
 | 
				
			||||||
        subject = (request.form.get("subject") or "").strip()
 | 
					        subject = request.form["subject"]
 | 
				
			||||||
        body_html = request.form.get("body") or ""
 | 
					        body = request.form["body"]
 | 
				
			||||||
 | 
					        result_message = process_send_update_email(subject, body)
 | 
				
			||||||
        if not subject or not body_html:
 | 
					        flash(result_message)
 | 
				
			||||||
            flash("Subject and body are required", "danger")
 | 
					 | 
				
			||||||
        return redirect(url_for("send_update"))
 | 
					        return redirect(url_for("send_update"))
 | 
				
			||||||
 | 
					 | 
				
			||||||
        result_message = process_send_update_email(subject, body_html)
 | 
					 | 
				
			||||||
        flash(escape(result_message))
 | 
					 | 
				
			||||||
        return redirect(url_for("send_update"))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return render_template("send_update.html")
 | 
					    return render_template("send_update.html")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route("/login", methods=["GET", "POST"])
 | 
					@app.route("/login", methods=["GET", "POST"])
 | 
				
			||||||
def login():
 | 
					def login():
 | 
				
			||||||
    if request.method == "POST":
 | 
					    if request.method == "POST":
 | 
				
			||||||
        username = (request.form.get("username") or "").strip()
 | 
					        username = request.form.get("username")
 | 
				
			||||||
        password = (request.form.get("password") or "").strip()
 | 
					        password = request.form.get("password")
 | 
				
			||||||
 | 
					 | 
				
			||||||
        if not username or not password:
 | 
					 | 
				
			||||||
            flash("Username and password are required", "danger")
 | 
					 | 
				
			||||||
            return redirect(url_for("login"))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        admin = get_admin(username)
 | 
					        admin = get_admin(username)
 | 
				
			||||||
        if admin and check_password_hash(admin[1], password):
 | 
					        if admin and check_password_hash(admin[1], password):
 | 
				
			||||||
            session["username"] = username
 | 
					            session["username"] = username
 | 
				
			||||||
            session.permanent = True
 | 
					 | 
				
			||||||
            app.config["SESSION_COOKIE_HTTPONLY"] = True
 | 
					 | 
				
			||||||
            app.config["SESSION_COOKIE_SECURE"] = True
 | 
					 | 
				
			||||||
            app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            next_url = request.args.get("next")
 | 
					 | 
				
			||||||
            if next_url and is_safe_url(next_url):
 | 
					 | 
				
			||||||
                flash("Logged in successfully", "success")
 | 
					 | 
				
			||||||
                return redirect(next_url)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            flash("Logged in successfully", "success")
 | 
					            flash("Logged in successfully", "success")
 | 
				
			||||||
            return redirect(url_for("index"))
 | 
					            return redirect(url_for("index"))
 | 
				
			||||||
        else:
 | 
					        else:
 | 
				
			||||||
            flash("Invalid username or password", "danger")
 | 
					            flash("Invalid username or password", "danger")
 | 
				
			||||||
            return redirect(url_for("login"))
 | 
					            return redirect(url_for("login"))
 | 
				
			||||||
 | 
					 | 
				
			||||||
    return render_template("login.html")
 | 
					    return render_template("login.html")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@app.route("/logout", methods=["GET"])
 | 
					@app.route("/logout")
 | 
				
			||||||
def logout():
 | 
					def logout():
 | 
				
			||||||
    session.pop("username", None)
 | 
					    session.pop("username", None)
 | 
				
			||||||
    flash("Logged out successfully", "success")
 | 
					    flash("Logged out successfully", "success")
 | 
				
			||||||
| 
						 | 
					@ -236,9 +147,4 @@ def logout():
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
if __name__ == "__main__":
 | 
					if __name__ == "__main__":
 | 
				
			||||||
    is_prod = os.getenv("ENVIRONMENT", "development").lower() == "production"
 | 
					    app.run(port=5001, debug=True)
 | 
				
			||||||
    app.config["PREFERRED_URL_SCHEME"] = "https" if is_prod else "http"
 | 
					 | 
				
			||||||
    if is_prod:
 | 
					 | 
				
			||||||
        app.run(host="0.0.0.0", port=5001, debug=False, use_reloader=False)
 | 
					 | 
				
			||||||
    else:
 | 
					 | 
				
			||||||
        app.run(host="0.0.0.0", port=5001, debug=True, use_reloader=True)
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										149
									
								
								database.py
									
										
									
									
									
								
							
							
						
						
									
										149
									
								
								database.py
									
										
									
									
									
								
							| 
						 | 
					@ -1,18 +1,23 @@
 | 
				
			||||||
import os
 | 
					import os
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
import psycopg2
 | 
					import psycopg2
 | 
				
			||||||
from psycopg2 import IntegrityError, pool, OperationalError
 | 
					from psycopg2 import IntegrityError
 | 
				
			||||||
from dotenv import load_dotenv
 | 
					from dotenv import load_dotenv
 | 
				
			||||||
from werkzeug.security import generate_password_hash
 | 
					from werkzeug.security import generate_password_hash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
load_dotenv()
 | 
					load_dotenv()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
try:
 | 
					# Logging setup
 | 
				
			||||||
    DB_MIN_CONN = int(os.getenv("DB_MIN_CONN", 1))
 | 
					logging.basicConfig(
 | 
				
			||||||
    DB_MAX_CONN = int(os.getenv("DB_MAX_CONN", 10))
 | 
					    level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					logger = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    conn_pool = pool.ThreadedConnectionPool(
 | 
					
 | 
				
			||||||
        minconn=DB_MIN_CONN,
 | 
					def get_connection():
 | 
				
			||||||
        maxconn=DB_MAX_CONN,
 | 
					    """Return a new connection to the PostgreSQL database."""
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        conn = psycopg2.connect(
 | 
				
			||||||
            host=os.getenv("PG_HOST"),
 | 
					            host=os.getenv("PG_HOST"),
 | 
				
			||||||
            port=os.getenv("PG_PORT"),
 | 
					            port=os.getenv("PG_PORT"),
 | 
				
			||||||
            dbname=os.getenv("PG_DATABASE"),
 | 
					            dbname=os.getenv("PG_DATABASE"),
 | 
				
			||||||
| 
						 | 
					@ -20,25 +25,20 @@ try:
 | 
				
			||||||
            password=os.getenv("PG_PASSWORD"),
 | 
					            password=os.getenv("PG_PASSWORD"),
 | 
				
			||||||
            connect_timeout=10,
 | 
					            connect_timeout=10,
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
except OperationalError:
 | 
					        return conn
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logger.error(f"Database connection error: {e}")
 | 
				
			||||||
        raise
 | 
					        raise
 | 
				
			||||||
except Exception:
 | 
					 | 
				
			||||||
    raise
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def get_connection():
 | 
					 | 
				
			||||||
    """Get a connection from the connection pool."""
 | 
					 | 
				
			||||||
    return conn_pool.getconn()
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def init_db():
 | 
					def init_db():
 | 
				
			||||||
    """Initialize database tables with connection pool."""
 | 
					    """Initialize the database tables."""
 | 
				
			||||||
    conn = None
 | 
					    conn = None
 | 
				
			||||||
    cursor = None
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        conn = get_connection()
 | 
					        conn = get_connection()
 | 
				
			||||||
        cursor = conn.cursor()
 | 
					        cursor = conn.cursor()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Create subscribers table (if not exists)
 | 
				
			||||||
        cursor.execute(
 | 
					        cursor.execute(
 | 
				
			||||||
            """
 | 
					            """
 | 
				
			||||||
            CREATE TABLE IF NOT EXISTS subscribers (
 | 
					            CREATE TABLE IF NOT EXISTS subscribers (
 | 
				
			||||||
| 
						 | 
					@ -48,6 +48,7 @@ def init_db():
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Create admin_users table (if not exists)
 | 
				
			||||||
        cursor.execute(
 | 
					        cursor.execute(
 | 
				
			||||||
            """
 | 
					            """
 | 
				
			||||||
            CREATE TABLE IF NOT EXISTS admin_users (
 | 
					            CREATE TABLE IF NOT EXISTS admin_users (
 | 
				
			||||||
| 
						 | 
					@ -58,6 +59,7 @@ def init_db():
 | 
				
			||||||
        """
 | 
					        """
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Newsletter storage
 | 
				
			||||||
        cursor.execute(
 | 
					        cursor.execute(
 | 
				
			||||||
            """
 | 
					            """
 | 
				
			||||||
        CREATE TABLE IF NOT EXISTS newsletters (
 | 
					        CREATE TABLE IF NOT EXISTS newsletters (
 | 
				
			||||||
| 
						 | 
					@ -70,81 +72,78 @@ def init_db():
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        conn.commit()
 | 
					        conn.commit()
 | 
				
			||||||
    except Exception:
 | 
					        logger.info("Database initialized successfully.")
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logger.error(f"Database initialization error: {e}")
 | 
				
			||||||
        if conn:
 | 
					        if conn:
 | 
				
			||||||
            conn.rollback()
 | 
					            conn.rollback()  # Rollback if there's an error
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        raise
 | 
					        raise
 | 
				
			||||||
    finally:
 | 
					    finally:
 | 
				
			||||||
        if cursor:
 | 
					 | 
				
			||||||
            cursor.close()
 | 
					 | 
				
			||||||
        if conn:
 | 
					        if conn:
 | 
				
			||||||
            conn_pool.putconn(conn)
 | 
					            cursor.close()
 | 
				
			||||||
 | 
					            conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_all_emails():
 | 
					def get_all_emails():
 | 
				
			||||||
    """Return a list of all subscriber emails."""
 | 
					    """Return a list of all subscriber emails."""
 | 
				
			||||||
    conn = None
 | 
					 | 
				
			||||||
    cursor = None
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        conn = get_connection()
 | 
					        conn = get_connection()
 | 
				
			||||||
        cursor = conn.cursor()
 | 
					        cursor = conn.cursor()
 | 
				
			||||||
        cursor.execute("SELECT email FROM subscribers")
 | 
					        cursor.execute("SELECT email FROM subscribers")
 | 
				
			||||||
        results = cursor.fetchall()
 | 
					        results = cursor.fetchall()
 | 
				
			||||||
        return [row[0] for row in results]
 | 
					        emails = [row[0] for row in results]
 | 
				
			||||||
    except Exception:
 | 
					        logger.debug(f"Retrieved emails: {emails}")
 | 
				
			||||||
 | 
					        return emails
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logger.error(f"Error retrieving emails: {e}")
 | 
				
			||||||
        return []
 | 
					        return []
 | 
				
			||||||
    finally:
 | 
					    finally:
 | 
				
			||||||
        if cursor:
 | 
					 | 
				
			||||||
            cursor.close()
 | 
					 | 
				
			||||||
        if conn:
 | 
					        if conn:
 | 
				
			||||||
            conn_pool.putconn(conn)
 | 
					            cursor.close()
 | 
				
			||||||
 | 
					            conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def add_email(email):
 | 
					def add_email(email):
 | 
				
			||||||
    """Insert an email into the subscribers table."""
 | 
					    """Insert an email into the subscribers table."""
 | 
				
			||||||
    conn = None
 | 
					    conn = None
 | 
				
			||||||
    cursor = None
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        conn = get_connection()
 | 
					        conn = get_connection()
 | 
				
			||||||
        cursor = conn.cursor()
 | 
					        cursor = conn.cursor()
 | 
				
			||||||
        cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,))
 | 
					        cursor.execute("INSERT INTO subscribers (email) VALUES (%s)", (email,))
 | 
				
			||||||
        conn.commit()
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        logger.info(f"Email {email} added successfully.")
 | 
				
			||||||
        return True
 | 
					        return True
 | 
				
			||||||
    except IntegrityError:
 | 
					    except IntegrityError:
 | 
				
			||||||
        if conn:
 | 
					        logger.warning(f"Attempted to add duplicate email: {email}")
 | 
				
			||||||
            conn.rollback()
 | 
					 | 
				
			||||||
        return False
 | 
					        return False
 | 
				
			||||||
    except Exception:
 | 
					    except Exception as e:
 | 
				
			||||||
        if conn:
 | 
					        logger.error(f"Error adding email {email}: {e}")
 | 
				
			||||||
            conn.rollback()
 | 
					 | 
				
			||||||
        return False
 | 
					        return False
 | 
				
			||||||
    finally:
 | 
					    finally:
 | 
				
			||||||
        if cursor:
 | 
					 | 
				
			||||||
            cursor.close()
 | 
					 | 
				
			||||||
        if conn:
 | 
					        if conn:
 | 
				
			||||||
            conn_pool.putconn(conn)
 | 
					            cursor.close()
 | 
				
			||||||
 | 
					            conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def remove_email(email):
 | 
					def remove_email(email):
 | 
				
			||||||
    """Remove an email from the subscribers table."""
 | 
					    """Remove an email from the subscribers table."""
 | 
				
			||||||
    conn = None
 | 
					    conn = None
 | 
				
			||||||
    cursor = None
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        conn = get_connection()
 | 
					        conn = get_connection()
 | 
				
			||||||
        cursor = conn.cursor()
 | 
					        cursor = conn.cursor()
 | 
				
			||||||
        cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,))
 | 
					        cursor.execute("DELETE FROM subscribers WHERE email = %s", (email,))
 | 
				
			||||||
        rowcount = cursor.rowcount
 | 
					        rowcount = cursor.rowcount
 | 
				
			||||||
        conn.commit()
 | 
					        conn.commit()
 | 
				
			||||||
 | 
					        logger.info(f"Email {email} removed successfully.")
 | 
				
			||||||
        return rowcount > 0
 | 
					        return rowcount > 0
 | 
				
			||||||
    except Exception:
 | 
					    except Exception as e:
 | 
				
			||||||
        if conn:
 | 
					        logger.error(f"Error removing email {email}: {e}")
 | 
				
			||||||
            conn.rollback()
 | 
					 | 
				
			||||||
        return False
 | 
					        return False
 | 
				
			||||||
    finally:
 | 
					    finally:
 | 
				
			||||||
        if cursor:
 | 
					 | 
				
			||||||
            cursor.close()
 | 
					 | 
				
			||||||
        if conn:
 | 
					        if conn:
 | 
				
			||||||
            conn_pool.putconn(conn)
 | 
					            cursor.close()
 | 
				
			||||||
 | 
					            conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def get_admin(username):
 | 
					def get_admin(username):
 | 
				
			||||||
| 
						 | 
					@ -152,7 +151,6 @@ def get_admin(username):
 | 
				
			||||||
    Returns a tuple (username, password_hash) if found, otherwise None.
 | 
					    Returns a tuple (username, password_hash) if found, otherwise None.
 | 
				
			||||||
    """
 | 
					    """
 | 
				
			||||||
    conn = None
 | 
					    conn = None
 | 
				
			||||||
    cursor = None
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        conn = get_connection()
 | 
					        conn = get_connection()
 | 
				
			||||||
        cursor = conn.cursor()
 | 
					        cursor = conn.cursor()
 | 
				
			||||||
| 
						 | 
					@ -160,14 +158,15 @@ def get_admin(username):
 | 
				
			||||||
            "SELECT username, password FROM admin_users WHERE username = %s",
 | 
					            "SELECT username, password FROM admin_users WHERE username = %s",
 | 
				
			||||||
            (username,),
 | 
					            (username,),
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        return cursor.fetchone()
 | 
					        result = cursor.fetchone()
 | 
				
			||||||
    except Exception:
 | 
					        return result  # (username, password_hash)
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logger.error(f"Error retrieving admin: {e}")
 | 
				
			||||||
        return None
 | 
					        return None
 | 
				
			||||||
    finally:
 | 
					    finally:
 | 
				
			||||||
        if cursor:
 | 
					 | 
				
			||||||
            cursor.close()
 | 
					 | 
				
			||||||
        if conn:
 | 
					        if conn:
 | 
				
			||||||
            conn_pool.putconn(conn)
 | 
					            cursor.close()
 | 
				
			||||||
 | 
					            conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
def create_default_admin():
 | 
					def create_default_admin():
 | 
				
			||||||
| 
						 | 
					@ -175,60 +174,30 @@ def create_default_admin():
 | 
				
			||||||
    default_username = os.getenv("ADMIN_USERNAME", "admin")
 | 
					    default_username = os.getenv("ADMIN_USERNAME", "admin")
 | 
				
			||||||
    default_password = os.getenv("ADMIN_PASSWORD", "changeme")
 | 
					    default_password = os.getenv("ADMIN_PASSWORD", "changeme")
 | 
				
			||||||
    hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256")
 | 
					    hashed_password = generate_password_hash(default_password, method="pbkdf2:sha256")
 | 
				
			||||||
 | 
					 | 
				
			||||||
    conn = None
 | 
					    conn = None
 | 
				
			||||||
    cursor = None
 | 
					 | 
				
			||||||
    try:
 | 
					    try:
 | 
				
			||||||
        conn = get_connection()
 | 
					        conn = get_connection()
 | 
				
			||||||
        cursor = conn.cursor()
 | 
					        cursor = conn.cursor()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Check if the admin already exists
 | 
				
			||||||
        cursor.execute(
 | 
					        cursor.execute(
 | 
				
			||||||
            "SELECT id FROM admin_users WHERE username = %s", (default_username,)
 | 
					            "SELECT id FROM admin_users WHERE username = %s", (default_username,)
 | 
				
			||||||
        )
 | 
					        )
 | 
				
			||||||
        exists = cursor.fetchone()
 | 
					        if cursor.fetchone() is None:
 | 
				
			||||||
        if exists is None:
 | 
					 | 
				
			||||||
            cursor.execute(
 | 
					            cursor.execute(
 | 
				
			||||||
                "INSERT INTO admin_users (username, password) VALUES (%s, %s)",
 | 
					                "INSERT INTO admin_users (username, password) VALUES (%s, %s)",
 | 
				
			||||||
                (default_username, hashed_password),
 | 
					                (default_username, hashed_password),
 | 
				
			||||||
            )
 | 
					            )
 | 
				
			||||||
            conn.commit()
 | 
					            conn.commit()
 | 
				
			||||||
    except Exception:
 | 
					            logger.info("Default admin created successfully")
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            logger.info("Default admin already exists")
 | 
				
			||||||
 | 
					    except Exception as e:
 | 
				
			||||||
 | 
					        logger.error(f"Error creating default admin: {e}")
 | 
				
			||||||
        if conn:
 | 
					        if conn:
 | 
				
			||||||
            conn.rollback()
 | 
					            conn.rollback()
 | 
				
			||||||
    finally:
 | 
					    finally:
 | 
				
			||||||
        if cursor:
 | 
					 | 
				
			||||||
            cursor.close()
 | 
					 | 
				
			||||||
        if conn:
 | 
					        if conn:
 | 
				
			||||||
            conn_pool.putconn(conn)
 | 
					            cursor.close()
 | 
				
			||||||
 | 
					            conn.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
def close_pool():
 | 
					 | 
				
			||||||
    """Close the database connection pool."""
 | 
					 | 
				
			||||||
    try:
 | 
					 | 
				
			||||||
        conn_pool.closeall()
 | 
					 | 
				
			||||||
    except Exception:
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class DatabaseContext:
 | 
					 | 
				
			||||||
    """Optional context manager for manual transactions."""
 | 
					 | 
				
			||||||
    def __init__(self):
 | 
					 | 
				
			||||||
        self.conn = None
 | 
					 | 
				
			||||||
        self.cursor = None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __enter__(self):
 | 
					 | 
				
			||||||
        self.conn = get_connection()
 | 
					 | 
				
			||||||
        self.cursor = self.conn.cursor()
 | 
					 | 
				
			||||||
        return self.cursor
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __exit__(self, exc_type, exc_val, exc_tb):
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            if exc_type:
 | 
					 | 
				
			||||||
                self.conn.rollback()
 | 
					 | 
				
			||||||
            else:
 | 
					 | 
				
			||||||
                self.conn.commit()
 | 
					 | 
				
			||||||
        finally:
 | 
					 | 
				
			||||||
            if self.cursor:
 | 
					 | 
				
			||||||
                self.cursor.close()
 | 
					 | 
				
			||||||
            if self.conn:
 | 
					 | 
				
			||||||
                conn_pool.putconn(self.conn)
 | 
					 | 
				
			||||||
| 
						 | 
					@ -3,6 +3,3 @@ flask
 | 
				
			||||||
python-dotenv
 | 
					python-dotenv
 | 
				
			||||||
Werkzeug
 | 
					Werkzeug
 | 
				
			||||||
psycopg2-binary
 | 
					psycopg2-binary
 | 
				
			||||||
psycopg2-pool
 | 
					 | 
				
			||||||
python-decouple
 | 
					 | 
				
			||||||
markupsafe
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,306 +1,55 @@
 | 
				
			||||||
:root {
 | 
					 | 
				
			||||||
  --primary: #2563eb;
 | 
					 | 
				
			||||||
  --primary-hover: #1d4ed8;
 | 
					 | 
				
			||||||
  --bg: #f5f7fb;
 | 
					 | 
				
			||||||
  --bg-grad-1: #f8fbff;
 | 
					 | 
				
			||||||
  --bg-grad-2: #eef3fb;
 | 
					 | 
				
			||||||
  --surface: #ffffff;
 | 
					 | 
				
			||||||
  --text: #0f172a;
 | 
					 | 
				
			||||||
  --muted: #64748b;
 | 
					 | 
				
			||||||
  --border: #e5e7eb;
 | 
					 | 
				
			||||||
  --ring: rgba(37, 99, 235, 0.25);
 | 
					 | 
				
			||||||
  --radius: 14px;
 | 
					 | 
				
			||||||
  --shadow-1: 0 8px 24px rgba(15, 23, 42, 0.08);
 | 
					 | 
				
			||||||
  --shadow-2: 0 14px 38px rgba(15, 23, 42, 0.12);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
*,
 | 
					 | 
				
			||||||
*::before,
 | 
					 | 
				
			||||||
*::after {
 | 
					 | 
				
			||||||
  box-sizing: border-box;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
html, body {
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
  padding: 0;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
body {
 | 
					body {
 | 
				
			||||||
  font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
 | 
					    font-family: Arial, sans-serif;
 | 
				
			||||||
  color: var(--text);
 | 
					 | 
				
			||||||
  background: radial-gradient(1000px 650px at 0% 0%, var(--bg-grad-1), transparent 60%),
 | 
					 | 
				
			||||||
              radial-gradient(900px 600px at 100% 0%, var(--bg-grad-2), transparent 55%),
 | 
					 | 
				
			||||||
              var(--bg);
 | 
					 | 
				
			||||||
  min-height: 100vh;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.navbar {
 | 
					 | 
				
			||||||
  position: sticky;
 | 
					 | 
				
			||||||
  top: 0;
 | 
					 | 
				
			||||||
  z-index: 1000;
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.8);
 | 
					 | 
				
			||||||
  backdrop-filter: blur(10px);
 | 
					 | 
				
			||||||
  border-bottom: 1px solid rgba(15, 23, 42, 0.06);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-content {
 | 
					 | 
				
			||||||
  max-width: 1100px;
 | 
					 | 
				
			||||||
  margin: 0 auto;
 | 
					 | 
				
			||||||
  padding: 14px 20px;
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  align-items: center;
 | 
					 | 
				
			||||||
  justify-content: space-between;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.brand {
 | 
					 | 
				
			||||||
  color: var(--text);
 | 
					 | 
				
			||||||
  font-weight: 700;
 | 
					 | 
				
			||||||
  text-decoration: none;
 | 
					 | 
				
			||||||
  letter-spacing: 0.2px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-links a {
 | 
					 | 
				
			||||||
  color: var(--muted);
 | 
					 | 
				
			||||||
  text-decoration: none;
 | 
					 | 
				
			||||||
  margin-left: 14px;
 | 
					 | 
				
			||||||
  padding: 8px 12px;
 | 
					 | 
				
			||||||
  border-radius: 10px;
 | 
					 | 
				
			||||||
  transition: all 0.2s ease;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-links a:hover {
 | 
					 | 
				
			||||||
  color: var(--text);
 | 
					 | 
				
			||||||
  background: rgba(15, 23, 42, 0.05);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-links .logout {
 | 
					 | 
				
			||||||
  color: #b91c1c;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.navbar-links .logout:hover {
 | 
					 | 
				
			||||||
  background: rgba(185, 28, 28, 0.08);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.container {
 | 
					 | 
				
			||||||
  max-width: 1100px;
 | 
					 | 
				
			||||||
  margin: 28px auto 48px;
 | 
					 | 
				
			||||||
  padding: 0 20px;
 | 
					 | 
				
			||||||
  padding-bottom: 64px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.footer {
 | 
					 | 
				
			||||||
  position: fixed;
 | 
					 | 
				
			||||||
  left: 0;
 | 
					 | 
				
			||||||
  right: 0;
 | 
					 | 
				
			||||||
  bottom: 0;
 | 
					 | 
				
			||||||
  z-index: 1000;
 | 
					 | 
				
			||||||
  border-top: 1px solid rgba(15, 23, 42, 0.06);
 | 
					 | 
				
			||||||
  background: rgba(255, 255, 255, 0.92);
 | 
					 | 
				
			||||||
  backdrop-filter: blur(8px);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.footer-inner {
 | 
					 | 
				
			||||||
  max-width: 1100px;
 | 
					 | 
				
			||||||
  margin: 0 auto;
 | 
					 | 
				
			||||||
  padding: 14px 20px;
 | 
					 | 
				
			||||||
  color: var(--muted);
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.page-header {
 | 
					 | 
				
			||||||
  margin-bottom: 18px;
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  align-items: end;
 | 
					 | 
				
			||||||
  justify-content: space-between;
 | 
					 | 
				
			||||||
  gap: 12px;
 | 
					 | 
				
			||||||
  flex-wrap: wrap;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.page-title {
 | 
					 | 
				
			||||||
  margin: 0 0 4px 0;
 | 
					 | 
				
			||||||
  font-size: 26px;
 | 
					 | 
				
			||||||
  font-weight: 700;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.page-subtitle {
 | 
					 | 
				
			||||||
  margin: 0;
 | 
					 | 
				
			||||||
  color: var(--muted);
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.page-actions {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  gap: 10px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.card {
 | 
					 | 
				
			||||||
  background: var(--surface);
 | 
					 | 
				
			||||||
  border: 1px solid var(--border);
 | 
					 | 
				
			||||||
  border-radius: var(--radius);
 | 
					 | 
				
			||||||
  box-shadow: var(--shadow-1);
 | 
					 | 
				
			||||||
    padding: 20px;
 | 
					    padding: 20px;
 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.empty-state {
 | 
					 | 
				
			||||||
  text-align: center;
 | 
					 | 
				
			||||||
  color: var(--muted);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.widgets {
 | 
					 | 
				
			||||||
  display: grid;
 | 
					 | 
				
			||||||
  grid-template-columns: repeat(3, minmax(0, 1fr));
 | 
					 | 
				
			||||||
  gap: 16px;
 | 
					 | 
				
			||||||
  margin-bottom: 18px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.widget-card {
 | 
					 | 
				
			||||||
  background: linear-gradient(180deg, #fff, #fafcff);
 | 
					 | 
				
			||||||
  border: 1px solid var(--border);
 | 
					 | 
				
			||||||
  border-radius: var(--radius);
 | 
					 | 
				
			||||||
  box-shadow: var(--shadow-1);
 | 
					 | 
				
			||||||
  padding: 18px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.widget-label {
 | 
					 | 
				
			||||||
  color: var(--muted);
 | 
					 | 
				
			||||||
  font-weight: 600;
 | 
					 | 
				
			||||||
  font-size: 13px;
 | 
					 | 
				
			||||||
  margin-bottom: 8px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.widget-value {
 | 
					 | 
				
			||||||
  font-size: 28px;
 | 
					 | 
				
			||||||
  font-weight: 800;
 | 
					 | 
				
			||||||
  letter-spacing: 0.3px;
 | 
					 | 
				
			||||||
  color: var(--text);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.table-wrap {
 | 
					 | 
				
			||||||
  overflow: hidden;
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  border: 1px solid var(--border);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.table {
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
  border-collapse: collapse;
 | 
					 | 
				
			||||||
  background: transparent;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.table thead th {
 | 
					 | 
				
			||||||
  text-align: left;
 | 
					 | 
				
			||||||
  font-weight: 600;
 | 
					 | 
				
			||||||
  color: var(--muted);
 | 
					 | 
				
			||||||
  font-size: 13px;
 | 
					 | 
				
			||||||
  letter-spacing: 0.3px;
 | 
					 | 
				
			||||||
  background: #f9fafb;
 | 
					 | 
				
			||||||
  padding: 12px 14px;
 | 
					 | 
				
			||||||
  border-bottom: 1px solid var(--border);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.table tbody td {
 | 
					 | 
				
			||||||
  padding: 14px;
 | 
					 | 
				
			||||||
  border-bottom: 1px solid #f1f5f9;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.table tbody tr:hover td {
 | 
					 | 
				
			||||||
  background: #f9fbff;
 | 
					 | 
				
			||||||
  transition: background 0.15s ease;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.form {
 | 
					 | 
				
			||||||
  display: grid;
 | 
					 | 
				
			||||||
  gap: 16px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.form-group {
 | 
					 | 
				
			||||||
  display: grid;
 | 
					 | 
				
			||||||
  gap: 8px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.form-group label {
 | 
					 | 
				
			||||||
  font-weight: 600;
 | 
					 | 
				
			||||||
  color: #334155;
 | 
					 | 
				
			||||||
  font-size: 14px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.form-group input,
 | 
					 | 
				
			||||||
.form-group textarea {
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
  color: var(--text);
 | 
					 | 
				
			||||||
  background: #ffffff;
 | 
					 | 
				
			||||||
  border: 1px solid var(--border);
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  padding: 12px 14px;
 | 
					 | 
				
			||||||
  font-size: 15px;
 | 
					 | 
				
			||||||
  transition: box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.form-group textarea {
 | 
					 | 
				
			||||||
  resize: vertical;
 | 
					 | 
				
			||||||
  min-height: 160px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.form-group input:focus,
 | 
					 | 
				
			||||||
.form-group textarea:focus {
 | 
					 | 
				
			||||||
  outline: none;
 | 
					 | 
				
			||||||
  border-color: #a7c2ff;
 | 
					 | 
				
			||||||
  box-shadow: 0 0 0 4px var(--ring);
 | 
					 | 
				
			||||||
  background: #ffffff;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.form-actions {
 | 
					 | 
				
			||||||
  display: flex;
 | 
					 | 
				
			||||||
  gap: 10px;
 | 
					 | 
				
			||||||
  justify-content: flex-end;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.button {
 | 
					 | 
				
			||||||
  appearance: none;
 | 
					 | 
				
			||||||
  border: 1px solid var(--border);
 | 
					 | 
				
			||||||
  background: #ffffff;
 | 
					 | 
				
			||||||
  color: var(--text);
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  padding: 10px 14px;
 | 
					 | 
				
			||||||
  cursor: pointer;
 | 
					 | 
				
			||||||
  font-weight: 600;
 | 
					 | 
				
			||||||
  transition: transform 0.05s ease, background 0.2s ease, border 0.2s ease;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.button:hover {
 | 
					 | 
				
			||||||
  background: #f5f7fb;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.button:active {
 | 
					 | 
				
			||||||
  transform: translateY(1px);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.button-primary {
 | 
					 | 
				
			||||||
  color: #ffffff;
 | 
					 | 
				
			||||||
  background: linear-gradient(180deg, #3b82f6, var(--primary));
 | 
					 | 
				
			||||||
  border-color: rgba(37, 99, 235, 0.4);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.button-primary:hover {
 | 
					 | 
				
			||||||
  background: linear-gradient(180deg, #2f74ed, var(--primary-hover));
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.button-secondary {
 | 
					 | 
				
			||||||
  background: #ffffff;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.flash-stack {
 | 
					 | 
				
			||||||
  display: grid;
 | 
					 | 
				
			||||||
  gap: 10px;
 | 
					 | 
				
			||||||
  margin-bottom: 18px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.flash {
 | 
					 | 
				
			||||||
  border-radius: 12px;
 | 
					 | 
				
			||||||
  padding: 12px 14px;
 | 
					 | 
				
			||||||
  font-weight: 600;
 | 
					 | 
				
			||||||
  border: 1px solid var(--border);
 | 
					 | 
				
			||||||
  background: #ffffff;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.flash-success {
 | 
					 | 
				
			||||||
  border-color: #a7f3d0;
 | 
					 | 
				
			||||||
  background: #ecfdf5;
 | 
					 | 
				
			||||||
  color: #065f46;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.flash-danger,
 | 
					 | 
				
			||||||
.flash-error {
 | 
					 | 
				
			||||||
  border-color: #fecaca;
 | 
					 | 
				
			||||||
  background: #fef2f2;
 | 
					 | 
				
			||||||
  color: #991b1b;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.flash-warning {
 | 
					 | 
				
			||||||
  border-color: #fde68a;
 | 
					 | 
				
			||||||
  background: #fffbeb;
 | 
					 | 
				
			||||||
  color: #92400e;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
.auth-wrapper {
 | 
					 | 
				
			||||||
  display: grid;
 | 
					 | 
				
			||||||
  place-items: center;
 | 
					 | 
				
			||||||
  min-height: calc(100vh - 120px);
 | 
					 | 
				
			||||||
  padding-top: 40px;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
.auth-card {
 | 
					 | 
				
			||||||
  max-width: 420px;
 | 
					 | 
				
			||||||
  width: 100%;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@media (max-width: 900px) {
 | 
					 | 
				
			||||||
  .widgets {
 | 
					 | 
				
			||||||
    grid-template-columns: 1fr;
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					  
 | 
				
			||||||
 | 
					  table {
 | 
				
			||||||
 | 
					    border-collapse: collapse;
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  th,
 | 
				
			||||||
 | 
					  td {
 | 
				
			||||||
 | 
					    border: 1px solid #ddd;
 | 
				
			||||||
 | 
					    padding: 8px;
 | 
				
			||||||
 | 
					    text-align: left;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  th {
 | 
				
			||||||
 | 
					    background-color: #f2f2f2;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  a {
 | 
				
			||||||
 | 
					    margin-right: 10px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  form {
 | 
				
			||||||
 | 
					    max-width: 600px;
 | 
				
			||||||
 | 
					    margin: 0 auto;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  label {
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					    margin-top: 15px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  input[type="text"],
 | 
				
			||||||
 | 
					  input[type="password"],
 | 
				
			||||||
 | 
					  textarea {
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					    padding: 8px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  button {
 | 
				
			||||||
 | 
					    margin-top: 15px;
 | 
				
			||||||
 | 
					    padding: 10px 20px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
 | 
					  .flash {
 | 
				
			||||||
 | 
					    background-color: #f8d7da;
 | 
				
			||||||
 | 
					    color: #721c24;
 | 
				
			||||||
 | 
					    padding: 10px;
 | 
				
			||||||
 | 
					    margin-bottom: 10px;
 | 
				
			||||||
 | 
					    text-align: center;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					  
 | 
				
			||||||
| 
						 | 
					@ -1,35 +1,29 @@
 | 
				
			||||||
{% extends "base.html" %}
 | 
					<!DOCTYPE html>
 | 
				
			||||||
{% block title %}Dashboard{% endblock %}
 | 
					<html lang="en">
 | 
				
			||||||
{% block content %}
 | 
					<head>
 | 
				
			||||||
  <div class="page-header">
 | 
					    <meta charset="UTF-8">
 | 
				
			||||||
    <div>
 | 
					    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
				
			||||||
      <h1 class="page-title">Dashboard</h1>
 | 
					    <title>Admin Center - Subscribers</title>
 | 
				
			||||||
      <p class="page-subtitle">Quick overview of your mailing activity</p>
 | 
					    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
 | 
				
			||||||
    </div>
 | 
					</head>
 | 
				
			||||||
    <div class="page-actions">
 | 
					<body>
 | 
				
			||||||
      <a href="{{ url_for('send_update') }}" class="button button-primary">Send Update</a>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <section class="widgets">
 | 
					    <h1>Subscribers</h1>
 | 
				
			||||||
    <div class="widget-card">
 | 
					    <p>
 | 
				
			||||||
      <div class="widget-label">Total Subscribers</div>
 | 
					      <a href="{{ url_for('send_update') }}">Send Update Email</a>|
 | 
				
			||||||
      <div class="widget-value">{{ counts.total_subscribers }}</div>
 | 
					      <a href="{{ url_for('logout') }}">Logout</a>
 | 
				
			||||||
    </div>
 | 
					    </p>
 | 
				
			||||||
    <div class="widget-card">
 | 
					
 | 
				
			||||||
      <div class="widget-label">Newsletters Sent</div>
 | 
					    {% with messages = get_flashed_messages(with_categories=true) %}
 | 
				
			||||||
      <div class="widget-value">{{ counts.total_newsletters }}</div>
 | 
					      {% if messages %}
 | 
				
			||||||
    </div>
 | 
					        {% for category, message in messages %}
 | 
				
			||||||
    <div class="widget-card">
 | 
					          <div class="flash">{{ message }}</div>
 | 
				
			||||||
      <div class="widget-label">Sent Today</div>
 | 
					        {% endfor %}
 | 
				
			||||||
      <div class="widget-value">{{ counts.sent_today }}</div>
 | 
					      {% endif %}
 | 
				
			||||||
    </div>
 | 
					    {% endwith %}
 | 
				
			||||||
  </section>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    {% if emails %}
 | 
					    {% if emails %}
 | 
				
			||||||
    <div class="card">
 | 
					        <table>
 | 
				
			||||||
      <div class="table-wrap">
 | 
					 | 
				
			||||||
        <table class="table">
 | 
					 | 
				
			||||||
            <thead>
 | 
					            <thead>
 | 
				
			||||||
                <tr>
 | 
					                <tr>
 | 
				
			||||||
                    <th>Email Address</th>
 | 
					                    <th>Email Address</th>
 | 
				
			||||||
| 
						 | 
					@ -43,11 +37,8 @@
 | 
				
			||||||
                {% endfor %}
 | 
					                {% endfor %}
 | 
				
			||||||
            </tbody>
 | 
					            </tbody>
 | 
				
			||||||
        </table>
 | 
					        </table>
 | 
				
			||||||
      </div>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
    {% else %}
 | 
					    {% else %}
 | 
				
			||||||
    <div class="card empty-state">
 | 
					 | 
				
			||||||
        <p>No subscribers found.</p>
 | 
					        <p>No subscribers found.</p>
 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
    {% endif %}
 | 
					    {% endif %}
 | 
				
			||||||
{% endblock %}
 | 
					</body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,58 +0,0 @@
 | 
				
			||||||
<!DOCTYPE html>
 | 
					 | 
				
			||||||
<html lang="en">
 | 
					 | 
				
			||||||
<head>
 | 
					 | 
				
			||||||
  <meta charset="UTF-8" />
 | 
					 | 
				
			||||||
  <meta
 | 
					 | 
				
			||||||
    name="viewport"
 | 
					 | 
				
			||||||
    content="width=device-width, initial-scale=1.0, viewport-fit=cover"
 | 
					 | 
				
			||||||
  />
 | 
					 | 
				
			||||||
  <title>{% block title %}Admin{% endblock %}</title>
 | 
					 | 
				
			||||||
  <link
 | 
					 | 
				
			||||||
    rel="stylesheet"
 | 
					 | 
				
			||||||
    href="{{ url_for('static', filename='css/style.css') }}"
 | 
					 | 
				
			||||||
  />
 | 
					 | 
				
			||||||
  <link
 | 
					 | 
				
			||||||
    rel="preconnect"
 | 
					 | 
				
			||||||
    href="https://fonts.googleapis.com"
 | 
					 | 
				
			||||||
    crossorigin
 | 
					 | 
				
			||||||
  />
 | 
					 | 
				
			||||||
  <link
 | 
					 | 
				
			||||||
    href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
 | 
					 | 
				
			||||||
    rel="stylesheet"
 | 
					 | 
				
			||||||
  />
 | 
					 | 
				
			||||||
</head>
 | 
					 | 
				
			||||||
<body>
 | 
					 | 
				
			||||||
  <header class="navbar">
 | 
					 | 
				
			||||||
    <div class="navbar-content">
 | 
					 | 
				
			||||||
      <a class="brand" href="{{ url_for('index') }}">Admin Panel</a>
 | 
					 | 
				
			||||||
      <nav class="navbar-links">
 | 
					 | 
				
			||||||
        {% if session.get('username') %}
 | 
					 | 
				
			||||||
        <a href="{{ url_for('index') }}">Dashboard</a>
 | 
					 | 
				
			||||||
        <a href="{{ url_for('send_update') }}">Send Update</a>
 | 
					 | 
				
			||||||
        <a href="{{ url_for('logout') }}" class="logout">Logout</a>
 | 
					 | 
				
			||||||
        {% endif %}
 | 
					 | 
				
			||||||
      </nav>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </header>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <main class="container">
 | 
					 | 
				
			||||||
    {% with messages = get_flashed_messages(with_categories=true) %}
 | 
					 | 
				
			||||||
      {% if messages %}
 | 
					 | 
				
			||||||
        <div class="flash-stack">
 | 
					 | 
				
			||||||
          {% for category, message in messages %}
 | 
					 | 
				
			||||||
            <div class="flash flash-{{ category|lower }}">{{ message }}</div>
 | 
					 | 
				
			||||||
          {% endfor %}
 | 
					 | 
				
			||||||
        </div>
 | 
					 | 
				
			||||||
      {% endif %}
 | 
					 | 
				
			||||||
    {% endwith %}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    {% block content %}{% endblock %}
 | 
					 | 
				
			||||||
  </main>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  <footer class="footer">
 | 
					 | 
				
			||||||
    <div class="footer-inner">
 | 
					 | 
				
			||||||
      <span>© {{ 2025 }} Admin Panel</span>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </footer>
 | 
					 | 
				
			||||||
</body>
 | 
					 | 
				
			||||||
</html>
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,34 +1,29 @@
 | 
				
			||||||
{% extends "base.html" %}
 | 
					<!DOCTYPE html>
 | 
				
			||||||
{% block title %}Admin Login{% endblock %}
 | 
					<html lang="en">
 | 
				
			||||||
{% block content %}
 | 
					 | 
				
			||||||
  <section class="auth-wrapper">
 | 
					 | 
				
			||||||
    <div class="card auth-card">
 | 
					 | 
				
			||||||
      <h1 class="page-title">Welcome back</h1>
 | 
					 | 
				
			||||||
      <p class="page-subtitle">Sign in to manage your subscribers</p>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <form action="{{ url_for('login') }}" method="POST" class="form">
 | 
					<head>
 | 
				
			||||||
        <div class="form-group">
 | 
					  <meta charset="UTF-8">
 | 
				
			||||||
          <label for="username">Username</label>
 | 
					  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
				
			||||||
          <input
 | 
					  <title>Admin Login</title>
 | 
				
			||||||
            type="text"
 | 
					  <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
 | 
				
			||||||
            name="username"
 | 
					</head>
 | 
				
			||||||
            id="username"
 | 
					
 | 
				
			||||||
            autocomplete="username"
 | 
					<body>
 | 
				
			||||||
            required
 | 
					  <h1>Admin Login</h1>
 | 
				
			||||||
          />
 | 
					  {% with messages = get_flashed_messages(with_categories=true) %}
 | 
				
			||||||
        </div>
 | 
					  {% if messages %}
 | 
				
			||||||
        <div class="form-group">
 | 
					  {% for category, message in messages %}
 | 
				
			||||||
          <label for="password">Password</label>
 | 
					  <div class="flash">{{ message }}</div>
 | 
				
			||||||
          <input
 | 
					  {% endfor %}
 | 
				
			||||||
            type="password"
 | 
					  {% endif %}
 | 
				
			||||||
            name="password"
 | 
					  {% endwith %}
 | 
				
			||||||
            id="password"
 | 
					  <form action="{{ url_for('login') }}" method="POST">
 | 
				
			||||||
            autocomplete="current-password"
 | 
					    <label for="username">Username:</label>
 | 
				
			||||||
            required
 | 
					    <input type="text" name="username" required />
 | 
				
			||||||
          />
 | 
					    <label for="password">Password:</label>
 | 
				
			||||||
        </div>
 | 
					    <input type="password" name="password" required />
 | 
				
			||||||
        <button type="submit" class="button button-primary">Login</button>
 | 
					    <button type="submit">Login</button>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
    </div>
 | 
					</body>
 | 
				
			||||||
  </section>
 | 
					
 | 
				
			||||||
{% endblock %}
 | 
					</html>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,32 +1,37 @@
 | 
				
			||||||
{% extends "base.html" %}
 | 
					<!DOCTYPE html>
 | 
				
			||||||
{% block title %}Send Update{% endblock %}
 | 
					<html lang="en">
 | 
				
			||||||
{% block content %}
 | 
					 | 
				
			||||||
  <div class="page-header">
 | 
					 | 
				
			||||||
    <div>
 | 
					 | 
				
			||||||
      <h1 class="page-title">Send Update</h1>
 | 
					 | 
				
			||||||
      <p class="page-subtitle">Send an email update to all subscribers</p>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
    <div class="page-actions">
 | 
					 | 
				
			||||||
      <a href="{{ url_for('index') }}" class="button button-secondary">Dashboard</a>
 | 
					 | 
				
			||||||
    </div>
 | 
					 | 
				
			||||||
  </div>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  <div class="card">
 | 
					<head>
 | 
				
			||||||
    <form action="{{ url_for('send_update') }}" method="POST" class="form">
 | 
					  <meta charset="UTF-8">
 | 
				
			||||||
      <div class="form-group">
 | 
					  <meta name="viewport" content="width=device-width, initial-scale=1.0">
 | 
				
			||||||
        <label for="subject">Subject</label>
 | 
					  <title>Admin Center - Send Update</title>
 | 
				
			||||||
        <input type="text" name="subject" id="subject" required />
 | 
					  <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
 | 
				
			||||||
      </div>
 | 
					</head>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div class="form-group">
 | 
					<body>
 | 
				
			||||||
        <label for="body">Body (HTML allowed)</label>
 | 
					  <h1>Send Update Email</h1>
 | 
				
			||||||
        <textarea name="body" id="body" rows="12" required
 | 
					  <p>
 | 
				
			||||||
          placeholder="<h1>Title</h1><p>Your content...</p>"></textarea>
 | 
					    <a href="{{ url_for('index') }}">Back to Subscribers List</a> |
 | 
				
			||||||
      </div>
 | 
					    <a href="{{ url_for('logout') }}">Logout</a>
 | 
				
			||||||
 | 
					  </p>
 | 
				
			||||||
 | 
					  {% with messages = get_flashed_messages() %}
 | 
				
			||||||
 | 
					    {% if messages %}
 | 
				
			||||||
 | 
					      {% for message in messages %}
 | 
				
			||||||
 | 
					        <div class="flash">{{ message }}</div>
 | 
				
			||||||
 | 
					      {% endfor %}
 | 
				
			||||||
 | 
					    {% endif %}
 | 
				
			||||||
 | 
					  {% endwith %}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      <div class="form-actions">
 | 
					  <form action="{{ url_for('send_update') }}" method="POST">
 | 
				
			||||||
        <button type="submit" class="button button-primary">Send Update</button>
 | 
					    <label for="subject">Subject:</label>
 | 
				
			||||||
      </div>
 | 
					    <input type="text" name="subject" required>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <label for="body">Body (HTML allowed):</label>
 | 
				
			||||||
 | 
					    <textarea name="body" rows="10" required></textarea>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    <button type="submit">Send Update</button>
 | 
				
			||||||
  </form>
 | 
					  </form>
 | 
				
			||||||
  </div>
 | 
					
 | 
				
			||||||
{% endblock %}
 | 
					</body>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue