refactor: init overhaul of admin panel
This commit is contained in:
parent
9d78f1fdb4
commit
69468dc5bf
6 changed files with 2686 additions and 295 deletions
412
app.py
412
app.py
|
|
@ -2,6 +2,9 @@ import os
|
|||
import logging
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
import concurrent.futures
|
||||
from threading import Lock
|
||||
from flask import (
|
||||
Flask,
|
||||
render_template,
|
||||
|
|
@ -10,23 +13,35 @@ from flask import (
|
|||
url_for,
|
||||
flash,
|
||||
session,
|
||||
jsonify,
|
||||
abort
|
||||
)
|
||||
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 functools import wraps
|
||||
import re
|
||||
from database import (
|
||||
get_db_connection, init_db, get_all_emails, get_admin, create_default_admin,
|
||||
get_subscriber_stats, add_email, remove_email, save_newsletter,
|
||||
update_newsletter_stats, log_email_delivery, get_recent_newsletters
|
||||
)
|
||||
|
||||
load_dotenv()
|
||||
app = Flask(__name__)
|
||||
app.secret_key = os.getenv("SECRET_KEY")
|
||||
base_url = os.getenv("BASE_URL")
|
||||
|
||||
# SMTP settings (for sending update emails)
|
||||
# SMTP settings
|
||||
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)
|
||||
SENDER_NAME = os.getenv("SENDER_NAME", "Newsletter Admin")
|
||||
|
||||
# Email sending configuration
|
||||
MAX_EMAIL_WORKERS = 5
|
||||
email_send_lock = Lock()
|
||||
|
||||
# Logging setup
|
||||
logging.basicConfig(
|
||||
|
|
@ -34,117 +49,366 @@ logging.basicConfig(
|
|||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize the database and create default admin user if necessary.
|
||||
# Initialize the database and create default admin user
|
||||
init_db()
|
||||
create_default_admin()
|
||||
|
||||
# Decorator for requiring login
|
||||
# Security decorators
|
||||
def login_required(f):
|
||||
@wraps(f) # Use wraps to preserve function metadata
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if "username" not in session:
|
||||
if request.is_json:
|
||||
return jsonify({"error": "Authentication required"}), 401
|
||||
flash("Please log in to access this page.", "warning")
|
||||
return redirect(url_for("login"))
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
def validate_email(email):
|
||||
"""Validate email format."""
|
||||
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
return re.match(pattern, email) is not None
|
||||
|
||||
def send_update_email(subject, body, email):
|
||||
"""Sends email, returns True on success, False on failure."""
|
||||
def send_single_email(email_data):
|
||||
"""Send a single email (for thread pool execution)."""
|
||||
email, subject, body, newsletter_id = email_data
|
||||
try:
|
||||
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=10)
|
||||
server.set_debuglevel(False) # Keep debug level at False for production
|
||||
server = smtplib.SMTP_SSL(SMTP_SERVER, SMTP_PORT, timeout=15)
|
||||
server.login(SMTP_USER, SMTP_PASSWORD)
|
||||
|
||||
# Create message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = f"{SENDER_NAME} <{SENDER_EMAIL}>"
|
||||
msg['To'] = email
|
||||
|
||||
# Create unsubscribe link
|
||||
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>"
|
||||
)
|
||||
|
||||
# HTML body with unsubscribe link
|
||||
html_body = f"""
|
||||
<html>
|
||||
<body>
|
||||
{body}
|
||||
<hr style="margin-top: 40px; border: none; border-top: 1px solid #eee;">
|
||||
<p style="font-size: 12px; color: #888; text-align: center;">
|
||||
If you wish to unsubscribe, please <a href="{unsub_link}" style="color: #888;">click here</a>
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
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
|
||||
# Attach HTML part
|
||||
html_part = MIMEText(html_body, 'html')
|
||||
msg.attach(html_part)
|
||||
|
||||
server.sendmail(SENDER_EMAIL, email, msg.as_string())
|
||||
server.quit()
|
||||
logger.info(f"Update email sent to: {email}")
|
||||
return True
|
||||
|
||||
# Log successful delivery
|
||||
if newsletter_id:
|
||||
log_email_delivery(newsletter_id, email, 'sent')
|
||||
|
||||
logger.info(f"Email sent successfully to: {email}")
|
||||
return True, email, None
|
||||
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()
|
||||
|
||||
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}"
|
||||
error_msg = str(e)
|
||||
logger.error(f"Failed to send email to {email}: {error_msg}")
|
||||
|
||||
# Log failed delivery
|
||||
if newsletter_id:
|
||||
log_email_delivery(newsletter_id, email, 'failed', error_msg)
|
||||
|
||||
return False, email, error_msg
|
||||
|
||||
def send_newsletter_batch(subject, body, email_list, newsletter_id=None):
|
||||
"""Send newsletter to multiple recipients using thread pool."""
|
||||
success_count = 0
|
||||
failure_count = 0
|
||||
failed_emails = []
|
||||
|
||||
# Prepare email data for thread pool
|
||||
email_data_list = [(email, subject, body, newsletter_id) for email in email_list]
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_EMAIL_WORKERS) as executor:
|
||||
future_to_email = {
|
||||
executor.submit(send_single_email, email_data): email_data[0]
|
||||
for email_data in email_data_list
|
||||
}
|
||||
|
||||
for future in concurrent.futures.as_completed(future_to_email):
|
||||
success, email, error = future.result()
|
||||
if success:
|
||||
success_count += 1
|
||||
else:
|
||||
failure_count += 1
|
||||
failed_emails.append({'email': email, 'error': error})
|
||||
|
||||
return success_count, failure_count, failed_emails
|
||||
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
"""Displays all subscriber emails"""
|
||||
emails = get_all_emails()
|
||||
return render_template("admin_index.html", emails=emails)
|
||||
"""Dashboard with subscriber statistics and recent activity."""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
search = request.args.get('search', '')
|
||||
per_page = 25
|
||||
|
||||
stats = get_subscriber_stats()
|
||||
subscribers_data = get_all_emails(page=page, per_page=per_page, search=search)
|
||||
recent_newsletters = get_recent_newsletters(limit=5)
|
||||
|
||||
return render_template(
|
||||
"admin_index.html",
|
||||
stats=stats,
|
||||
subscribers=subscribers_data['subscribers'],
|
||||
pagination={
|
||||
'page': subscribers_data['page'],
|
||||
'per_page': subscribers_data['per_page'],
|
||||
'total_pages': subscribers_data['total_pages'],
|
||||
'total_count': subscribers_data['total_count']
|
||||
},
|
||||
search=search,
|
||||
recent_newsletters=recent_newsletters
|
||||
)
|
||||
|
||||
@app.route("/send_update", methods=["GET", "POST"])
|
||||
@app.route("/subscribers")
|
||||
@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)
|
||||
return redirect(url_for("send_update"))
|
||||
return render_template("send_update.html")
|
||||
def subscribers():
|
||||
"""Detailed subscriber management page."""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
search = request.args.get('search', '')
|
||||
per_page = 50
|
||||
|
||||
subscribers_data = get_all_emails(page=page, per_page=per_page, search=search)
|
||||
|
||||
return render_template(
|
||||
"subscribers.html",
|
||||
subscribers=subscribers_data['subscribers'],
|
||||
pagination={
|
||||
'page': subscribers_data['page'],
|
||||
'per_page': subscribers_data['per_page'],
|
||||
'total_pages': subscribers_data['total_pages'],
|
||||
'total_count': subscribers_data['total_count']
|
||||
},
|
||||
search=search
|
||||
)
|
||||
|
||||
@app.route("/add_subscriber", methods=["POST"])
|
||||
@login_required
|
||||
def add_subscriber():
|
||||
"""Add a new subscriber via AJAX."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip().lower()
|
||||
|
||||
if not email or not validate_email(email):
|
||||
return jsonify({"success": False, "message": "Invalid email format"}), 400
|
||||
|
||||
success = add_email(email, source='admin_manual')
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": f"Successfully added {email}"})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "Email already exists or failed to add"}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding subscriber: {e}")
|
||||
return jsonify({"success": False, "message": "Server error occurred"}), 500
|
||||
|
||||
@app.route("/remove_subscriber", methods=["POST"])
|
||||
@login_required
|
||||
def remove_subscriber():
|
||||
"""Remove/unsubscribe a subscriber via AJAX."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
email = data.get('email', '').strip().lower()
|
||||
|
||||
if not email:
|
||||
return jsonify({"success": False, "message": "Email is required"}), 400
|
||||
|
||||
success = remove_email(email)
|
||||
|
||||
if success:
|
||||
return jsonify({"success": True, "message": f"Successfully unsubscribed {email}"})
|
||||
else:
|
||||
return jsonify({"success": False, "message": "Email not found"}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing subscriber: {e}")
|
||||
return jsonify({"success": False, "message": "Server error occurred"}), 500
|
||||
|
||||
@app.route("/send_newsletter", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def send_newsletter():
|
||||
"""Enhanced newsletter sending with preview and batch processing."""
|
||||
if request.method == "POST":
|
||||
action = request.form.get('action', 'send')
|
||||
subject = request.form.get('subject', '').strip()
|
||||
body = request.form.get('body', '').strip()
|
||||
|
||||
if not subject or not body:
|
||||
flash("Subject and body are required", "error")
|
||||
return redirect(url_for('send_newsletter'))
|
||||
|
||||
if action == 'preview':
|
||||
# Return preview
|
||||
preview_html = f"""
|
||||
<div class="email-preview">
|
||||
<h3>Subject: {subject}</h3>
|
||||
<div class="email-body">{body}</div>
|
||||
<hr>
|
||||
<p><small>Unsubscribe link will be automatically added to all emails</small></p>
|
||||
</div>
|
||||
"""
|
||||
return render_template("send_newsletter.html", preview=preview_html, subject=subject, body=body)
|
||||
|
||||
elif action == 'send':
|
||||
# Get all active subscribers (backwards compatible)
|
||||
try:
|
||||
with get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if status column exists for backwards compatibility
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_name = 'subscribers' AND column_name = 'status'
|
||||
)
|
||||
"""
|
||||
)
|
||||
has_status = cursor.fetchone()[0]
|
||||
|
||||
if has_status:
|
||||
cursor.execute("SELECT email FROM subscribers WHERE status = 'active'")
|
||||
else:
|
||||
cursor.execute("SELECT email FROM subscribers")
|
||||
|
||||
email_list = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching subscriber emails: {e}")
|
||||
flash("Error retrieving subscriber list", "error")
|
||||
return redirect(url_for('send_newsletter'))
|
||||
|
||||
if not email_list:
|
||||
flash("No active subscribers found", "warning")
|
||||
return redirect(url_for('send_newsletter'))
|
||||
|
||||
# Save newsletter to database
|
||||
newsletter_id = save_newsletter(subject, body, session['username'], len(email_list))
|
||||
|
||||
# Send emails in batches
|
||||
try:
|
||||
success_count, failure_count, failed_emails = send_newsletter_batch(
|
||||
subject, body, email_list, newsletter_id
|
||||
)
|
||||
|
||||
# Update newsletter statistics
|
||||
if newsletter_id:
|
||||
update_newsletter_stats(newsletter_id, success_count, failure_count)
|
||||
|
||||
# Flash results
|
||||
if success_count > 0:
|
||||
flash(f"Newsletter sent successfully to {success_count} subscribers!", "success")
|
||||
|
||||
if failure_count > 0:
|
||||
flash(f"Failed to send to {failure_count} subscribers", "error")
|
||||
for failed in failed_emails[:5]: # Show first 5 failures
|
||||
flash(f"Failed: {failed['email']} - {failed['error'][:100]}", "warning")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending newsletter: {e}")
|
||||
flash(f"Error sending newsletter: {str(e)}", "error")
|
||||
|
||||
return redirect(url_for('send_newsletter'))
|
||||
|
||||
return render_template("send_newsletter.html")
|
||||
|
||||
@app.route("/newsletter_history")
|
||||
@login_required
|
||||
def newsletter_history():
|
||||
"""View newsletter sending history."""
|
||||
newsletters = get_recent_newsletters(limit=50)
|
||||
return render_template("newsletter_history.html", newsletters=newsletters)
|
||||
|
||||
@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", "").strip()
|
||||
password = request.form.get("password", "")
|
||||
|
||||
if not username or not password:
|
||||
flash("Username and password are required", "error")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
admin = get_admin(username)
|
||||
if admin and check_password_hash(admin[1], password):
|
||||
if admin and len(admin) >= 3 and check_password_hash(admin[2], password):
|
||||
if len(admin) >= 4 and not admin[3]: # Check is_active
|
||||
flash("Account is disabled", "error")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
session["username"] = username
|
||||
flash("Logged in successfully", "success")
|
||||
session["admin_id"] = admin[0]
|
||||
flash("Logged in successfully!", "success")
|
||||
|
||||
# Redirect to intended page or dashboard
|
||||
next_page = request.args.get('next')
|
||||
if next_page:
|
||||
return redirect(next_page)
|
||||
return redirect(url_for("index"))
|
||||
else:
|
||||
flash("Invalid username or password", "danger")
|
||||
flash("Invalid username or password", "error")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
session.pop("username", None)
|
||||
flash("Logged out successfully", "success")
|
||||
session.clear()
|
||||
flash("You have been logged out successfully", "info")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
# Public unsubscribe endpoint
|
||||
@app.route("/unsubscribe")
|
||||
def unsubscribe():
|
||||
"""Public unsubscribe endpoint."""
|
||||
email = request.args.get('email', '').strip().lower()
|
||||
|
||||
if not email or not validate_email(email):
|
||||
return render_template("unsubscribe.html", error="Invalid email address")
|
||||
|
||||
success = remove_email(email)
|
||||
|
||||
if success:
|
||||
return render_template("unsubscribe.html", success=True, email=email)
|
||||
else:
|
||||
return render_template("unsubscribe.html", error="Email not found or already unsubscribed")
|
||||
|
||||
# API endpoints for AJAX requests
|
||||
@app.route("/api/stats")
|
||||
@login_required
|
||||
def api_stats():
|
||||
"""API endpoint for dashboard statistics."""
|
||||
stats = get_subscriber_stats()
|
||||
return jsonify(stats)
|
||||
|
||||
# Error handlers
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return render_template("error.html", error="Page not found", code=404), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
logger.error(f"Internal error: {error}")
|
||||
return render_template("error.html", error="Internal server error", code=500), 500
|
||||
|
||||
# Context processors
|
||||
@app.context_processor
|
||||
def inject_user():
|
||||
return dict(current_user=session.get('username'))
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(port=5001, debug=True)
|
||||
app.run(host="0.0.0.0", port=5001, debug=True)
|
||||
Loading…
Add table
Add a link
Reference in a new issue