v1
This commit is contained in:
15
server/routes/__init__.py
Normal file
15
server/routes/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Blueprint registration for the server application."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from . import admin, auth, contact, monitoring, newsletter
|
||||
|
||||
|
||||
def register_blueprints(app: Flask) -> None:
|
||||
"""Register all HTTP blueprints with the Flask app."""
|
||||
app.register_blueprint(contact.bp)
|
||||
app.register_blueprint(newsletter.bp)
|
||||
app.register_blueprint(monitoring.bp)
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
377
server/routes/admin.py
Normal file
377
server/routes/admin.py
Normal file
@@ -0,0 +1,377 @@
|
||||
"""Admin routes for application management."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, render_template, jsonify, request
|
||||
import logging
|
||||
|
||||
from .. import auth, settings
|
||||
from ..database import delete_app_setting, get_app_settings, get_subscribers, update_app_setting
|
||||
|
||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
@bp.route("/")
|
||||
@auth.login_required
|
||||
def dashboard():
|
||||
"""Display admin dashboard overview."""
|
||||
return render_template("admin_dashboard.html")
|
||||
|
||||
|
||||
@bp.route("/newsletter")
|
||||
@auth.login_required
|
||||
def newsletter_subscribers():
|
||||
"""Display newsletter subscriber management page."""
|
||||
return render_template("admin_newsletter.html")
|
||||
|
||||
|
||||
@bp.route("/newsletter/create")
|
||||
@auth.login_required
|
||||
def newsletter_create():
|
||||
"""Display newsletter creation and sending page."""
|
||||
return render_template("admin_newsletter_create.html")
|
||||
|
||||
|
||||
@bp.route("/settings")
|
||||
@auth.login_required
|
||||
def settings_page():
|
||||
"""Display current application settings."""
|
||||
# Gather settings to display
|
||||
app_settings = {
|
||||
"Database": {
|
||||
"DATABASE_URL": settings.DATABASE_URL or "sqlite:///./data/forms.db",
|
||||
"POSTGRES_URL": settings.POSTGRES_URL or "Not configured",
|
||||
"SQLite Path": str(settings.SQLITE_DB_PATH),
|
||||
},
|
||||
"SMTP": {
|
||||
"Host": settings.SMTP_SETTINGS["host"] or "Not configured",
|
||||
"Port": settings.SMTP_SETTINGS["port"],
|
||||
"Username": settings.SMTP_SETTINGS["username"] or "Not configured",
|
||||
"Sender": settings.SMTP_SETTINGS["sender"] or "Not configured",
|
||||
"Recipients": ", ".join(settings.SMTP_SETTINGS["recipients"]) if settings.SMTP_SETTINGS["recipients"] else "Not configured",
|
||||
"Use TLS": settings.SMTP_SETTINGS["use_tls"],
|
||||
},
|
||||
"Rate Limiting": {
|
||||
"Max Requests": settings.RATE_LIMIT_MAX,
|
||||
"Window (seconds)": settings.RATE_LIMIT_WINDOW,
|
||||
"Redis URL": settings.REDIS_URL or "Not configured",
|
||||
},
|
||||
"Security": {
|
||||
"Strict Origin Check": settings.STRICT_ORIGIN_CHECK,
|
||||
"Allowed Origin": settings.ALLOWED_ORIGIN or "Not configured",
|
||||
},
|
||||
"Logging": {
|
||||
"JSON Logs": settings.ENABLE_JSON_LOGS,
|
||||
"Request Logs": settings.ENABLE_REQUEST_LOGS,
|
||||
},
|
||||
"Monitoring": {
|
||||
"Sentry DSN": settings.SENTRY_DSN or "Not configured",
|
||||
"Sentry Traces Sample Rate": settings.SENTRY_TRACES_SAMPLE_RATE,
|
||||
},
|
||||
"Admin": {
|
||||
"Username": settings.ADMIN_USERNAME,
|
||||
},
|
||||
}
|
||||
|
||||
return render_template("admin_settings.html", settings=app_settings)
|
||||
|
||||
|
||||
@bp.route("/submissions")
|
||||
@auth.login_required
|
||||
def submissions():
|
||||
"""Display contact form submissions page."""
|
||||
return render_template("admin_submissions.html")
|
||||
|
||||
|
||||
@bp.route("/api/settings", methods=["GET"])
|
||||
@auth.login_required
|
||||
def get_settings_api():
|
||||
"""Get all application settings via API."""
|
||||
try:
|
||||
settings_data = get_app_settings()
|
||||
return jsonify({"status": "ok", "settings": settings_data})
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to retrieve settings: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Failed to retrieve settings."}), 500
|
||||
|
||||
|
||||
def validate_setting(key: str, value: str) -> str | None:
|
||||
"""Validate a setting key-value pair. Returns error message or None if valid."""
|
||||
# Define validation rules for known settings
|
||||
validations = {
|
||||
"maintenance_mode": lambda v: v in ["true", "false"],
|
||||
"contact_form_enabled": lambda v: v in ["true", "false"],
|
||||
"newsletter_enabled": lambda v: v in ["true", "false"],
|
||||
"rate_limit_max": lambda v: v.isdigit() and 0 <= int(v) <= 1000,
|
||||
"rate_limit_window": lambda v: v.isdigit() and 1 <= int(v) <= 3600,
|
||||
}
|
||||
|
||||
if key in validations and not validations[key](value):
|
||||
return f"Invalid value for {key}"
|
||||
|
||||
# General validation
|
||||
if len(key) > 100:
|
||||
return "Setting key too long (max 100 characters)"
|
||||
if len(value) > 1000:
|
||||
return "Setting value too long (max 1000 characters)"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@bp.route("/api/settings/<key>", methods=["PUT"])
|
||||
@auth.login_required
|
||||
def update_setting_api(key: str):
|
||||
"""Update a specific application setting via API."""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
value = data.get("value", "").strip()
|
||||
|
||||
if not value:
|
||||
return jsonify({"status": "error", "message": "Value is required."}), 400
|
||||
|
||||
# Validate the setting
|
||||
validation_error = validate_setting(key, value)
|
||||
if validation_error:
|
||||
return jsonify({"status": "error", "message": validation_error}), 400
|
||||
|
||||
success = update_app_setting(key, value)
|
||||
if success:
|
||||
return jsonify({"status": "ok", "message": f"Setting '{key}' updated successfully."})
|
||||
else:
|
||||
return jsonify({"status": "error", "message": "Failed to update setting."}), 500
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to update setting: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Failed to update setting."}), 500
|
||||
|
||||
|
||||
@bp.route("/api/settings/<key>", methods=["DELETE"])
|
||||
@auth.login_required
|
||||
def delete_setting_api(key: str):
|
||||
"""Delete a specific application setting via API."""
|
||||
try:
|
||||
deleted = delete_app_setting(key)
|
||||
if deleted:
|
||||
return jsonify({"status": "ok", "message": f"Setting '{key}' deleted successfully."})
|
||||
else:
|
||||
return jsonify({"status": "error", "message": f"Setting '{key}' not found."}), 404
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to delete setting: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Failed to delete setting."}), 500
|
||||
|
||||
|
||||
@bp.route("/api/newsletter", methods=["GET"])
|
||||
@auth.login_required
|
||||
def get_subscribers_api():
|
||||
"""Retrieve newsletter subscribers with pagination, filtering, and sorting."""
|
||||
try:
|
||||
# Parse query parameters
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)),
|
||||
100) # Max 100 per page
|
||||
sort_by = request.args.get("sort_by", "subscribed_at")
|
||||
sort_order = request.args.get("sort_order", "desc")
|
||||
email_filter = request.args.get("email")
|
||||
|
||||
# Validate sort_by
|
||||
valid_sort_fields = ["email", "subscribed_at"]
|
||||
if sort_by not in valid_sort_fields:
|
||||
sort_by = "subscribed_at"
|
||||
|
||||
# Get subscribers
|
||||
subscribers, total = get_subscribers(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
email_filter=email_filter,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"subscribers": subscribers,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
})
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to retrieve subscribers: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Failed to retrieve subscribers."}), 500
|
||||
|
||||
|
||||
@bp.route("/api/newsletters", methods=["POST"])
|
||||
@auth.login_required
|
||||
def create_newsletter_api():
|
||||
"""Create a new newsletter."""
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
subject = data.get("subject", "").strip()
|
||||
content = data.get("content", "").strip()
|
||||
sender_name = data.get("sender_name", "").strip() or None
|
||||
send_date = data.get("send_date", "").strip() or None
|
||||
status = data.get("status", "draft")
|
||||
|
||||
if not subject or not content:
|
||||
return jsonify({"status": "error", "message": "Subject and content are required."}), 400
|
||||
|
||||
if status not in ["draft", "scheduled", "sent"]:
|
||||
return jsonify({"status": "error", "message": "Invalid status."}), 400
|
||||
|
||||
from ..database import save_newsletter
|
||||
newsletter_id = save_newsletter(
|
||||
subject, content, sender_name, send_date, status)
|
||||
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"message": "Newsletter created successfully.",
|
||||
"newsletter_id": newsletter_id
|
||||
}), 201
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to create newsletter: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Failed to create newsletter."}), 500
|
||||
|
||||
|
||||
@bp.route("/api/newsletters", methods=["GET"])
|
||||
@auth.login_required
|
||||
def get_newsletters_api():
|
||||
"""Retrieve newsletters with pagination and filtering."""
|
||||
try:
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 20)),
|
||||
50) # Max 50 per page
|
||||
status_filter = request.args.get("status")
|
||||
|
||||
from ..database import get_newsletters
|
||||
newsletters, total = get_newsletters(
|
||||
page=page, per_page=per_page, status_filter=status_filter)
|
||||
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"newsletters": newsletters,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
})
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to retrieve newsletters: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Failed to retrieve newsletters."}), 500
|
||||
|
||||
|
||||
@bp.route("/api/newsletters/<int:newsletter_id>/send", methods=["POST"])
|
||||
@auth.login_required
|
||||
def send_newsletter_api(newsletter_id: int):
|
||||
"""Send a newsletter to all subscribers."""
|
||||
try:
|
||||
from ..database import get_newsletter_by_id, update_newsletter_status, get_subscribers
|
||||
from ..services.newsletter import send_newsletter_to_subscribers
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Get the newsletter
|
||||
newsletter = get_newsletter_by_id(newsletter_id)
|
||||
if not newsletter:
|
||||
return jsonify({"status": "error", "message": "Newsletter not found."}), 404
|
||||
|
||||
if newsletter["status"] == "sent":
|
||||
return jsonify({"status": "error", "message": "Newsletter has already been sent."}), 400
|
||||
|
||||
# Get all subscribers
|
||||
subscribers, _ = get_subscribers(
|
||||
page=1, per_page=10000) # Get all subscribers
|
||||
if not subscribers:
|
||||
return jsonify({"status": "error", "message": "No subscribers found."}), 400
|
||||
|
||||
# Send the newsletter
|
||||
success_count = send_newsletter_to_subscribers(
|
||||
newsletter["subject"],
|
||||
newsletter["content"],
|
||||
[sub["email"] for sub in subscribers],
|
||||
newsletter["sender_name"]
|
||||
)
|
||||
|
||||
# Update newsletter status
|
||||
sent_at = datetime.now(timezone.utc).isoformat()
|
||||
update_newsletter_status(newsletter_id, "sent", sent_at)
|
||||
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"message": f"Newsletter sent to {success_count} subscribers.",
|
||||
"sent_count": success_count
|
||||
})
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to send newsletter: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Failed to send newsletter."}), 500
|
||||
|
||||
|
||||
@bp.route("/api/contact", methods=["GET"])
|
||||
@auth.login_required
|
||||
def get_contact_submissions_api():
|
||||
"""Retrieve contact form submissions with pagination, filtering, and sorting."""
|
||||
try:
|
||||
# Parse query parameters
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)),
|
||||
100) # Max 100 per page
|
||||
sort_by = request.args.get("sort_by", "created_at")
|
||||
sort_order = request.args.get("sort_order", "desc")
|
||||
email_filter = request.args.get("email")
|
||||
date_from = request.args.get("date_from")
|
||||
date_to = request.args.get("date_to")
|
||||
|
||||
# Validate sort_by
|
||||
valid_sort_fields = ["id", "name", "email", "created_at"]
|
||||
if sort_by not in valid_sort_fields:
|
||||
sort_by = "created_at"
|
||||
|
||||
# Get submissions
|
||||
from ..database import get_contacts
|
||||
submissions, total = get_contacts(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
email_filter=email_filter,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"submissions": submissions,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
})
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to retrieve contact submissions: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Failed to retrieve contact submissions."}), 500
|
||||
|
||||
|
||||
@bp.route("/api/contact/<int:contact_id>", methods=["DELETE"])
|
||||
@auth.login_required
|
||||
def delete_contact_submission_api(contact_id: int):
|
||||
"""Delete a contact submission by ID."""
|
||||
try:
|
||||
from ..database import delete_contact
|
||||
deleted = delete_contact(contact_id)
|
||||
if deleted:
|
||||
return jsonify({"status": "ok", "message": f"Contact submission {contact_id} deleted successfully."})
|
||||
else:
|
||||
return jsonify({"status": "error", "message": f"Contact submission {contact_id} not found."}), 404
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to delete contact submission: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Failed to delete contact submission."}), 500
|
||||
31
server/routes/auth.py
Normal file
31
server/routes/auth.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Authentication routes for admin access."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
|
||||
|
||||
from .. import settings
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Handle user login."""
|
||||
if request.method == "POST":
|
||||
username = request.form.get("username")
|
||||
password = request.form.get("password")
|
||||
|
||||
if username == settings.ADMIN_USERNAME and password == settings.ADMIN_PASSWORD:
|
||||
session["logged_in"] = True
|
||||
return redirect("/admin/")
|
||||
else:
|
||||
flash("Invalid credentials")
|
||||
|
||||
return render_template("login.html")
|
||||
|
||||
|
||||
@bp.route("/logout")
|
||||
def logout():
|
||||
"""Handle user logout."""
|
||||
session.pop("logged_in", None)
|
||||
return redirect("/auth/login")
|
||||
134
server/routes/contact.py
Normal file
134
server/routes/contact.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Contact submission routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from .. import auth, settings
|
||||
from ..database import delete_contact, get_contacts
|
||||
from ..rate_limit import allow_request
|
||||
from ..services.contact import persist_submission, send_notification, validate_submission
|
||||
|
||||
bp = Blueprint("contact", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@bp.route("/contact", methods=["POST"])
|
||||
def receive_contact():
|
||||
payload = request.form or request.get_json(silent=True) or {}
|
||||
|
||||
if settings.STRICT_ORIGIN_CHECK:
|
||||
origin = request.headers.get("Origin")
|
||||
referer = request.headers.get("Referer")
|
||||
allowed = settings.ALLOWED_ORIGIN
|
||||
if allowed:
|
||||
if origin and origin != allowed and not (referer and referer.startswith(allowed)):
|
||||
logging.warning(
|
||||
"Origin/Referer mismatch (origin=%s, referer=%s)", origin, referer)
|
||||
return jsonify({"status": "error", "message": "Invalid request origin."}), 403
|
||||
else:
|
||||
logging.warning(
|
||||
"STRICT_ORIGIN_CHECK enabled but ALLOWED_ORIGIN not set; skipping enforcement")
|
||||
|
||||
client_ip_source = request.headers.get(
|
||||
"X-Forwarded-For", request.remote_addr or "unknown")
|
||||
client_ip = client_ip_source.split(
|
||||
",")[0].strip() if client_ip_source else "unknown"
|
||||
|
||||
if not allow_request(client_ip):
|
||||
logging.warning("Rate limit reached for %s", client_ip)
|
||||
return (
|
||||
jsonify(
|
||||
{"status": "error", "message": "Too many submissions, please try later."}),
|
||||
429,
|
||||
)
|
||||
|
||||
submission, errors = validate_submission(payload)
|
||||
if errors:
|
||||
return jsonify({"status": "error", "errors": errors}), 400
|
||||
|
||||
assert submission is not None
|
||||
try:
|
||||
record_id = persist_submission(submission)
|
||||
except Exception as exc: # pragma: no cover - logged for diagnostics
|
||||
logging.exception("Failed to persist submission: %s", exc)
|
||||
return (
|
||||
jsonify({"status": "error", "message": "Could not store submission."}),
|
||||
500,
|
||||
)
|
||||
|
||||
email_sent = send_notification(submission)
|
||||
|
||||
status = 201 if email_sent else 202
|
||||
body = {
|
||||
"status": "ok",
|
||||
"id": record_id,
|
||||
"email": "sent" if email_sent else "pending",
|
||||
}
|
||||
|
||||
if not email_sent:
|
||||
body["message"] = "Submission stored but email dispatch is not configured."
|
||||
|
||||
return jsonify(body), status
|
||||
|
||||
|
||||
@bp.route("/contact", methods=["GET"])
|
||||
@auth.login_required
|
||||
def get_submissions():
|
||||
"""Retrieve contact form submissions with pagination, filtering, and sorting."""
|
||||
try:
|
||||
# Parse query parameters
|
||||
page = int(request.args.get("page", 1))
|
||||
per_page = min(int(request.args.get("per_page", 50)), 100) # Max 100 per page
|
||||
sort_by = request.args.get("sort_by", "created_at")
|
||||
sort_order = request.args.get("sort_order", "desc")
|
||||
email_filter = request.args.get("email")
|
||||
date_from = request.args.get("date_from")
|
||||
date_to = request.args.get("date_to")
|
||||
|
||||
# Validate sort_by
|
||||
valid_sort_fields = ["id", "name", "email", "created_at"]
|
||||
if sort_by not in valid_sort_fields:
|
||||
sort_by = "created_at"
|
||||
|
||||
# Get submissions
|
||||
submissions, total = get_contacts(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
sort_by=sort_by,
|
||||
sort_order=sort_order,
|
||||
email_filter=email_filter,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"submissions": submissions,
|
||||
"pagination": {
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": total,
|
||||
"pages": (total + per_page - 1) // per_page,
|
||||
},
|
||||
})
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to retrieve submissions: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Failed to retrieve submissions."}), 500
|
||||
|
||||
|
||||
@bp.route("/contact/<int:contact_id>", methods=["DELETE"])
|
||||
@auth.login_required
|
||||
def delete_submission(contact_id: int):
|
||||
"""Delete a contact submission by ID."""
|
||||
try:
|
||||
deleted = delete_contact(contact_id)
|
||||
if not deleted:
|
||||
return jsonify({"status": "error", "message": "Submission not found."}), 404
|
||||
|
||||
return jsonify({"status": "ok", "message": "Submission deleted successfully."})
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to delete submission: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Failed to delete submission."}), 500
|
||||
33
server/routes/monitoring.py
Normal file
33
server/routes/monitoring.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Operational monitoring routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
from ..database import db_cursor
|
||||
from ..metrics import export_metrics
|
||||
|
||||
bp = Blueprint("monitoring", __name__)
|
||||
|
||||
|
||||
@bp.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""Simple health endpoint used by orchestrators and Docker HEALTHCHECK."""
|
||||
try:
|
||||
with db_cursor(read_only=True) as (_, cur):
|
||||
cur.execute("SELECT 1")
|
||||
cur.fetchone()
|
||||
except Exception as exc: # pragma: no cover - logged for operators
|
||||
logging.exception("Health check DB failure: %s", exc)
|
||||
return jsonify({"status": "unhealthy"}), 500
|
||||
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
@bp.route("/metrics", methods=["GET"])
|
||||
def metrics():
|
||||
payload, status, headers = export_metrics()
|
||||
if isinstance(payload, dict):
|
||||
return jsonify(payload), status
|
||||
return payload, status, headers
|
||||
133
server/routes/newsletter.py
Normal file
133
server/routes/newsletter.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Newsletter subscription routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from flask import Blueprint, jsonify, request, render_template
|
||||
|
||||
from ..services import newsletter
|
||||
|
||||
bp = Blueprint("newsletter", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@bp.route("/newsletter", methods=["POST"])
|
||||
def subscribe():
|
||||
payload = request.form or request.get_json(silent=True) or {}
|
||||
email = (payload.get("email") or "").strip()
|
||||
|
||||
if not newsletter.validate_email(email):
|
||||
return jsonify({"status": "error", "message": "Valid email is required."}), 400
|
||||
|
||||
try:
|
||||
created = newsletter.subscribe(email)
|
||||
except Exception as exc: # pragma: no cover - errors are logged
|
||||
logging.exception("Failed to persist subscriber: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Could not store subscription."}), 500
|
||||
|
||||
if not created:
|
||||
logging.info("Newsletter subscription ignored (duplicate): %s", email)
|
||||
return jsonify({"status": "error", "message": "Email is already subscribed."}), 409
|
||||
|
||||
logging.info("New newsletter subscription: %s", email)
|
||||
return jsonify({"status": "ok", "message": "Subscribed successfully."}), 201
|
||||
|
||||
|
||||
@bp.route("/newsletter", methods=["DELETE"])
|
||||
def unsubscribe():
|
||||
payload = request.form or request.get_json(silent=True) or {}
|
||||
email = (payload.get("email") or "").strip()
|
||||
|
||||
if not newsletter.validate_email(email):
|
||||
return jsonify({"status": "error", "message": "Valid email is required."}), 400
|
||||
|
||||
try:
|
||||
deleted = newsletter.unsubscribe(email)
|
||||
except Exception as exc: # pragma: no cover - errors are logged
|
||||
logging.exception("Failed to remove subscriber: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Could not remove subscription."}), 500
|
||||
|
||||
if not deleted:
|
||||
logging.info(
|
||||
"Newsletter unsubscription ignored (not subscribed): %s", email)
|
||||
return jsonify({"status": "error", "message": "Email is not subscribed."}), 404
|
||||
|
||||
logging.info("Newsletter unsubscription: %s", email)
|
||||
return jsonify({"status": "ok", "message": "Unsubscribed successfully."}), 200
|
||||
|
||||
|
||||
@bp.route("/newsletter", methods=["PUT"])
|
||||
def update_subscription():
|
||||
payload = request.form or request.get_json(silent=True) or {}
|
||||
old_email = (payload.get("old_email") or "").strip()
|
||||
new_email = (payload.get("new_email") or "").strip()
|
||||
|
||||
if not newsletter.validate_email(old_email) or not newsletter.validate_email(new_email):
|
||||
return jsonify({"status": "error", "message": "Valid old and new emails are required."}), 400
|
||||
|
||||
try:
|
||||
updated = newsletter.update_email(old_email, new_email)
|
||||
except Exception as exc: # pragma: no cover - errors are logged
|
||||
logging.exception("Failed to update subscriber: %s", exc)
|
||||
return jsonify({"status": "error", "message": "Could not update subscription."}), 500
|
||||
|
||||
if not updated:
|
||||
return jsonify({"status": "error", "message": "Old email not found or new email already exists."}), 404
|
||||
|
||||
logging.info("Newsletter subscription updated: %s -> %s",
|
||||
old_email, new_email)
|
||||
return jsonify({"status": "ok", "message": "Subscription updated successfully."}), 200
|
||||
|
||||
|
||||
@bp.route("/newsletter/manage", methods=["GET", "POST"])
|
||||
def manage_subscription():
|
||||
"""Display newsletter subscription management page."""
|
||||
message = None
|
||||
message_type = None
|
||||
|
||||
if request.method == "POST":
|
||||
action = request.form.get("action")
|
||||
email = (request.form.get("email") or "").strip()
|
||||
|
||||
if not newsletter.validate_email(email):
|
||||
message = "Please enter a valid email address."
|
||||
message_type = "error"
|
||||
else:
|
||||
try:
|
||||
if action == "subscribe":
|
||||
created = newsletter.subscribe(email)
|
||||
if created:
|
||||
message = "Successfully subscribed to newsletter!"
|
||||
message_type = "success"
|
||||
else:
|
||||
message = "This email is already subscribed."
|
||||
message_type = "info"
|
||||
elif action == "unsubscribe":
|
||||
deleted = newsletter.unsubscribe(email)
|
||||
if deleted:
|
||||
message = "Successfully unsubscribed from newsletter."
|
||||
message_type = "success"
|
||||
else:
|
||||
message = "This email is not currently subscribed."
|
||||
message_type = "info"
|
||||
elif action == "update":
|
||||
old_email = (request.form.get("old_email") or "").strip()
|
||||
if not newsletter.validate_email(old_email):
|
||||
message = "Please enter a valid current email address."
|
||||
message_type = "error"
|
||||
elif old_email == email:
|
||||
message = "New email must be different from current email."
|
||||
message_type = "error"
|
||||
else:
|
||||
updated = newsletter.update_email(old_email, email)
|
||||
if updated:
|
||||
message = "Email address updated successfully!"
|
||||
message_type = "success"
|
||||
else:
|
||||
message = "Current email not found or new email already exists."
|
||||
message_type = "error"
|
||||
except Exception as exc:
|
||||
logging.exception("Failed to manage subscription: %s", exc)
|
||||
message = "An error occurred. Please try again."
|
||||
message_type = "error"
|
||||
|
||||
return render_template("newsletter_manage.html", message=message, message_type=message_type)
|
||||
Reference in New Issue
Block a user