Compare commits
15 Commits
03f26d7ac1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e192086833 | |||
| 2629f6b25f | |||
| 91e5f41871 | |||
| c1e3ce185f | |||
| 3a11b00fd5 | |||
| dc3a888da3 | |||
| b5a0817760 | |||
| 6763fd802c | |||
| 56840ac313 | |||
| f7695be8ef | |||
| 3a24e86c45 | |||
| 70b45fdafa | |||
| 4824472c77 | |||
| 912f80966b | |||
| d9dbcb5fd6 |
@@ -17,6 +17,9 @@ SMTP_PASSWORD=your-password
|
||||
SMTP_SENDER=web@example.com
|
||||
SMTP_RECIPIENTS=team@example.com
|
||||
SMTP_USE_TLS=true
|
||||
# Default notification toggles (admin UI can override these at runtime)
|
||||
EMAIL_NOTIFY_CONTACT_FORM=true
|
||||
EMAIL_NOTIFY_NEWSLETTER_SIGNUPS=false
|
||||
# Set to 1 to enable SMTP integration tests during CI/CD (requires valid SMTP settings)
|
||||
RUN_SMTP_INTEGRATION_TEST=0
|
||||
|
||||
|
||||
81
.github/workflows/ci.yml
vendored
81
.github/workflows/ci.yml
vendored
@@ -9,6 +9,8 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
env:
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -22,13 +24,21 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
# - name: Locate pip cache directory
|
||||
# id: pip-cache-dir
|
||||
# # extra output for debugging
|
||||
# run: |
|
||||
# echo "dir=$(python -m pip cache dir)" >> "$GITHUB_OUTPUT"
|
||||
# echo "dir=$(python -m pip cache dir)"
|
||||
|
||||
# - name: Cache pip
|
||||
# uses: actions/cache@v4
|
||||
# with:
|
||||
# path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
# key: ${{ runner.os }}-py-${{ matrix.python-version }}-pip-${{ hashFiles('**/requirements.txt') }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-py-${{ matrix.python-version }}-pip-
|
||||
# ${{ runner.os }}-pip-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
@@ -39,46 +49,61 @@ jobs:
|
||||
run: |
|
||||
pytest -q tests
|
||||
|
||||
# - name: Upload test results (artifact)
|
||||
# if: always()
|
||||
# uses: actions/upload-artifact@v4
|
||||
# with:
|
||||
# name: pytest-results
|
||||
# path: tests
|
||||
- name: Upload test results (artifact)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: pytest-results
|
||||
path: tests
|
||||
|
||||
build-image:
|
||||
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
env:
|
||||
DEFAULT_BRANCH: main
|
||||
REGISTRY_URL: ${{ secrets.REGISTRY_URL }}
|
||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Collect workflow metadata
|
||||
id: meta
|
||||
shell: bash
|
||||
run: |
|
||||
ref_name="${GITHUB_REF_NAME:-${GITHUB_REF##*/}}"
|
||||
event_name="${GITHUB_EVENT_NAME:-}"
|
||||
sha="${GITHUB_SHA:-}"
|
||||
|
||||
if [ "$ref_name" = "${DEFAULT_BRANCH:-main}" ]; then
|
||||
echo "on_default=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "on_default=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
echo "ref_name=$ref_name" >> "$GITHUB_OUTPUT"
|
||||
echo "event_name=$event_name" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up QEMU and Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry (best-effort)
|
||||
if: ${{ github.ref == 'refs/heads/main' }}
|
||||
if: ${{ steps.meta.outputs.on_default == 'true' }}
|
||||
uses: docker/login-action@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
registry: ${{ secrets.REGISTRY_URL }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
registry: ${{ env.REGISTRY_URL }}
|
||||
username: ${{ env.REGISTRY_USERNAME }}
|
||||
password: ${{ env.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Build (and optionally push) image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: ${{ github.ref == 'refs/heads/main' && github.event_name != 'pull_request' && (secrets.REGISTRY_URL != '' && secrets.REGISTRY_USERNAME != '' && secrets.REGISTRY_PASSWORD != '') }}
|
||||
push: ${{ steps.meta.outputs.on_default == 'true' && steps.meta.outputs.event_name != 'pull_request' && (env.REGISTRY_URL != '' && env.REGISTRY_USERNAME != '' && env.REGISTRY_PASSWORD != '') }}
|
||||
tags: |
|
||||
${{ secrets.REGISTRY_URL }}/allucanget/contact.allucanget.biz:latest
|
||||
${{ secrets.REGISTRY_URL }}/allucanget/contact.allucanget.biz:${{ github.sha }}
|
||||
|
||||
- name: Upload built image metadata
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: image-build-info
|
||||
path: .
|
||||
${{ env.REGISTRY_URL }}/allucanget/contact-server:latest
|
||||
${{ env.REGISTRY_URL }}/allucanget/contact-server:${{ steps.meta.outputs.sha }}
|
||||
|
||||
43
Dockerfile
43
Dockerfile
@@ -1,24 +1,38 @@
|
||||
# syntax=docker/dockerfile:1.5
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
ARG APT_PROXY=http://192.168.88.14:3142
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build deps
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
# Configure apt to use apt-cacher-ng (overrideable via --build-arg APT_PROXY="<url>")
|
||||
RUN printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' "$APT_PROXY" "$APT_PROXY" > /etc/apt/apt.conf.d/01proxy
|
||||
|
||||
# Install build deps (minimal)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install into a target directory
|
||||
COPY /requirements.txt /app/requirements.txt
|
||||
RUN python -m pip install --upgrade pip && \
|
||||
# install into a prefix so console_scripts (gunicorn) are placed into /app/_deps/bin
|
||||
python -m pip install --no-cache-dir --upgrade --prefix /app/_deps -r /app/requirements.txt
|
||||
# Copy only what's needed for dependency resolution to leverage cache
|
||||
COPY requirements.txt ./requirements.txt
|
||||
|
||||
# Use BuildKit cache mount for pip wheels/cache to speed up rebuilds when available
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
python -m pip install --upgrade pip \
|
||||
&& python -m pip install --prefix /app/_deps -r requirements.txt
|
||||
|
||||
# Copy application source
|
||||
COPY . /app/src
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
ARG APT_PROXY=http://192.168.88.14:3142
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Configure apt to use apt-cacher-ng in the runtime stage as well
|
||||
RUN printf 'Acquire::http::Proxy "%s";\nAcquire::https::Proxy "%s";\n' "$APT_PROXY" "$APT_PROXY" > /etc/apt/apt.conf.d/01proxy
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
|
||||
|
||||
@@ -27,17 +41,14 @@ COPY --from=builder /app/_deps /app/_deps
|
||||
ENV PYTHONPATH=/app/_deps/lib/python3.11/site-packages:/app
|
||||
ENV PATH=/app/_deps/bin:$PATH
|
||||
|
||||
# Copy application code
|
||||
# Copy application code and entrypoint
|
||||
COPY --from=builder /app/src /app
|
||||
|
||||
# Copy entrypoint and make executable
|
||||
COPY /entrypoint.sh /app/entrypoint.sh
|
||||
COPY entrypoint.sh /app/entrypoint.sh
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Ensure minimal runtime packages are present (curl used by healthcheck and some runtime scripts)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
ca-certificates \
|
||||
# Install only runtime packages required (curl for healthcheck). keep packages minimal.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /app/data \
|
||||
&& chown -R appuser:appgroup /app/data
|
||||
|
||||
99
README.md
99
README.md
@@ -1,6 +1,35 @@
|
||||
# Server README
|
||||
|
||||
Backend service for the contact website. The app accepts contact and newsletter submissions, persists them, applies rate limiting and origin checks, and sends notification emails when SMTP is configured. Includes admin authentication for accessing application settings and managing dynamic configuration.
|
||||
Backend service for a static website. The app accepts contact and newsletter submissions, persists them, applies rate limiting and origin checks, and sends notification emails when SMTP is configured. Includes admin authentication for accessing application settings and managing dynamic configuration.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Server README](#server-readme)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Overview](#overview)
|
||||
- [Architecture](#architecture)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Admin Access](#admin-access)
|
||||
- [API Surface](#api-surface)
|
||||
- [Embeddable Forms](#embeddable-forms)
|
||||
- [Running With Docker](#running-with-docker)
|
||||
- [Build manually](#build-manually)
|
||||
- [Run with explicit environment variables](#run-with-explicit-environment-variables)
|
||||
- [Run using docker-compose](#run-using-docker-compose)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Core runtime](#core-runtime)
|
||||
- [Admin authentication](#admin-authentication)
|
||||
- [Database configuration](#database-configuration)
|
||||
- [Email delivery](#email-delivery)
|
||||
- [Rate limiting and caching](#rate-limiting-and-caching)
|
||||
- [Request hardening](#request-hardening)
|
||||
- [Observability](#observability)
|
||||
- [Docker / Gunicorn runtime](#docker--gunicorn-runtime)
|
||||
- [Health Checks and Monitoring](#health-checks-and-monitoring)
|
||||
- [Testing](#testing)
|
||||
- [Deployment Notes](#deployment-notes)
|
||||
- [Admin Email Settings](#admin-email-settings)
|
||||
- [Email Templates](#email-templates)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -61,11 +90,12 @@ Access the admin interface at `http://127.0.0.1:5002/auth/login` using the confi
|
||||
- `GET /api/contact`: retrieves contact form submissions (admin only, requires authentication). Supports pagination (`page`, `per_page`), filtering (`email`, `date_from`, `date_to`), and sorting (`sort_by`, `sort_order`).
|
||||
- `GET /api/contact/<id>`: retrieves a specific contact submission by ID (admin only).
|
||||
- `DELETE /api/contact/<id>`: deletes a contact submission by ID (admin only).
|
||||
- `POST /api/newsletter`: subscribes an address and optional metadata to the newsletter list.
|
||||
- `POST /api/newsletter`: subscribes an address and optional metadata to the newsletter list. Sends a confirmation email if SMTP is configured.
|
||||
- `DELETE /api/newsletter`: unsubscribes an email address from the newsletter list.
|
||||
- `PUT /api/newsletter`: updates a subscriber's email address (requires `old_email` and `new_email`).
|
||||
- `GET /api/newsletter/manage`: displays HTML form for newsletter subscription management.
|
||||
- `POST /api/newsletter/manage`: processes subscription management actions (subscribe, unsubscribe, update).
|
||||
- `GET /newsletter/unsubscribe/confirmation`: displays unsubscription confirmation page.
|
||||
- `GET /health`: lightweight database connectivity check used for container health monitoring.
|
||||
- `GET /metrics`: Prometheus-compatible metrics endpoint (requires `ENABLE_REQUEST_LOGS` for detailed tracing).
|
||||
- `GET /admin/api/settings`: retrieves all application settings (admin only).
|
||||
@@ -77,13 +107,48 @@ Access the admin interface at `http://127.0.0.1:5002/auth/login` using the confi
|
||||
- `POST /admin/api/newsletters/<id>/send`: sends a newsletter to all subscribers (admin only).
|
||||
- `GET /admin/api/contact`: retrieves contact form submissions with pagination, filtering, and sorting (admin only).
|
||||
- `DELETE /admin/api/contact/<id>`: deletes a contact submission by ID (admin only).
|
||||
- `GET /embed/contact`: serves an HTML page with a contact form that can be embedded in an iframe on external sites.
|
||||
- `GET /embed/newsletter`: serves an HTML page with a newsletter subscription form that can be embedded in an iframe on external sites.
|
||||
|
||||
## Embeddable Forms
|
||||
|
||||
The application provides embeddable contact and newsletter subscription forms that can be integrated into other websites via iframe. CORS (Cross-Origin Resource Sharing) headers are automatically added to API endpoints to allow forms embedded on external sites to submit data successfully.
|
||||
|
||||
- `GET /embed/contact`: serves an HTML page with a contact form that can be embedded in an iframe on external sites.
|
||||
- `GET /embed/newsletter`: serves an HTML page with a newsletter subscription form that can be embedded in an iframe on external sites.
|
||||
|
||||
To embed the contact form on another website, use the following HTML code:
|
||||
|
||||
```html
|
||||
<iframe
|
||||
src="https://your-server-domain/embed/contact"
|
||||
width="600"
|
||||
height="400"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
```
|
||||
|
||||
To embed the newsletter subscription form:
|
||||
|
||||
```html
|
||||
<iframe
|
||||
src="https://your-server-domain/embed/newsletter"
|
||||
width="600"
|
||||
height="300"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
```
|
||||
|
||||
Replace `https://your-server-domain` with your actual server URL. The iframe codes are available in the admin settings page for easy copying.
|
||||
|
||||
## Running With Docker
|
||||
|
||||
### Build manually
|
||||
|
||||
```pwsh
|
||||
docker build -t contact.allucanget.biz -f Dockerfile .
|
||||
docker build -t backend-server -f Dockerfile .
|
||||
```
|
||||
|
||||
### Run with explicit environment variables
|
||||
@@ -96,7 +161,7 @@ docker run --rm -p 5002:5002 `
|
||||
-e SMTP_USERNAME=api@example.com `
|
||||
-e SMTP_PASSWORD=secret `
|
||||
-e SMTP_RECIPIENTS=hello@example.com `
|
||||
contact.allucanget.biz
|
||||
backend-server
|
||||
```
|
||||
|
||||
### Run using docker-compose
|
||||
@@ -145,7 +210,7 @@ When `POSTGRES_URL` is set and `psycopg2-binary` is available, the app prefers P
|
||||
### Email delivery
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ----------------- | ----------------------------------------------------------------------- | -------- |
|
||||
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------- |
|
||||
| `SMTP_HOST` | SMTP server hostname or IP. Leave empty to disable email notifications. | _(none)_ |
|
||||
| `SMTP_PORT` | SMTP server port. | `587` |
|
||||
| `SMTP_USERNAME` | Username for SMTP authentication. | _(none)_ |
|
||||
@@ -153,6 +218,8 @@ When `POSTGRES_URL` is set and `psycopg2-binary` is available, the app prefers P
|
||||
| `SMTP_SENDER` | Sender email address; defaults to `SMTP_USERNAME` when unset. | _(none)_ |
|
||||
| `SMTP_RECIPIENTS` | Comma-separated recipient list for notifications. | _(none)_ |
|
||||
| `SMTP_USE_TLS` | Enables STARTTLS when `true`. | `true` |
|
||||
| `EMAIL_NOTIFY_CONTACT_FORM` | Default toggle for sending emails when new contact submissions arrive. Can be overridden in the admin UI. | `true` |
|
||||
| `EMAIL_NOTIFY_NEWSLETTER_SIGNUPS` | Default toggle for sending notification/confirmation emails when new newsletter signups occur. Admin UI can override per deployment. | `false` |
|
||||
|
||||
### Rate limiting and caching
|
||||
|
||||
@@ -199,6 +266,24 @@ SMTP integration tests are skipped unless `RUN_SMTP_INTEGRATION_TEST=1` and vali
|
||||
|
||||
## Deployment Notes
|
||||
|
||||
- A single GitHub Actions workflow (`ci.yml`) runs pytest on every push/pull request, uploads the test directory as an artifact, and optionally builds the Docker image.
|
||||
- On pushes to `main` (or manual dispatch) the workflow builds the container and, when registry credentials are available, pushes tags to `git.allucanget.biz`.
|
||||
- A single Gitea Actions workflow (`.github/workflows/ci.yml`) exercises pytest on each push, pull request, or manual dispatch, then conditionally builds the Docker image.
|
||||
- When the default branch (`main`) runs and registry secrets (`REGISTRY_URL`, `REGISTRY_USERNAME`, `REGISTRY_PASSWORD`) are configured in Gitea, the workflow logs in and pushes both `latest` and commit-specific image tags.
|
||||
- Import or mirror the required reusable actions (`actions/checkout`, `actions/setup-python`, and the Docker actions) into your Gitea instance so that the workflow can resolve them.
|
||||
- For production use, deploy the container behind a load balancer or reverse proxy and supply the appropriate environment variables.
|
||||
|
||||
## Admin Email Settings
|
||||
|
||||
- Navigate to `/admin/email-settings` after authenticating to manage SMTP host, credentials, sender identity, notification recipients, and feature toggles.
|
||||
- Values saved through the form are persisted in the `app_settings` table and override any `.env` defaults until updated again.
|
||||
- The page surfaces validation feedback in-line and falls back to environment-sourced defaults when a field is left blank.
|
||||
- Toggle `Email notifications for new contact submissions` and `Confirmation emails for newsletter signups` to control runtime behaviour. These switches seed from `EMAIL_NOTIFY_CONTACT_FORM` and `EMAIL_NOTIFY_NEWSLETTER_SIGNUPS` respectively.
|
||||
|
||||
## Email Templates
|
||||
|
||||
The application supports customizable email templates for newsletter confirmations. Administrators can manage templates from the dedicated **Email Templates** page (`/admin/email-templates`), which provides a list/detail editor for each available template. Changes are persisted to dynamic settings, allowing runtime updates without redeploying the application. Templates use HTML format and should include an unsubscribe link for compliance with email regulations.
|
||||
|
||||
Default confirmation email includes:
|
||||
|
||||
- Welcome message
|
||||
- Unsubscribe instructions with link to `/newsletter/manage`
|
||||
- Customizable HTML content
|
||||
|
||||
1
iframe_contact.html
Normal file
1
iframe_contact.html
Normal file
@@ -0,0 +1 @@
|
||||
<iframe src="http://your-server-domain/embed/contact" width="600" height="400" frameborder="0" allowfullscreen></iframe>
|
||||
1
iframe_newsletter.html
Normal file
1
iframe_newsletter.html
Normal file
@@ -0,0 +1 @@
|
||||
<iframe src="http://your-server-domain/embed/newsletter" width="600" height="300" frameborder="0" allowfullscreen></iframe>
|
||||
@@ -44,7 +44,8 @@ def register_request_hooks(app: Flask) -> None:
|
||||
@app.after_request
|
||||
def add_request_id_header(response): # type: ignore[unused-ignore]
|
||||
try:
|
||||
rid = getattr(request, "request_id", None) or request.environ.get("HTTP_X_REQUEST_ID")
|
||||
rid = getattr(request, "request_id", None) or request.environ.get(
|
||||
"HTTP_X_REQUEST_ID")
|
||||
if rid:
|
||||
response.headers["X-Request-Id"] = rid
|
||||
|
||||
@@ -62,7 +63,27 @@ def register_request_hooks(app: Flask) -> None:
|
||||
pass
|
||||
|
||||
start_time = getattr(g, "_start_time", None)
|
||||
observe_request(request.method, request.path, start_time, response.status_code)
|
||||
observe_request(request.method, request.path,
|
||||
start_time, response.status_code)
|
||||
except Exception:
|
||||
pass
|
||||
return response
|
||||
|
||||
@app.after_request
|
||||
def add_cors_headers(response): # type: ignore[unused-ignore]
|
||||
# Add CORS headers for embedded forms
|
||||
if request.path.startswith("/api/"):
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
||||
|
||||
return response
|
||||
|
||||
@app.before_request
|
||||
def handle_options(): # type: ignore[unused-ignore]
|
||||
if request.method == "OPTIONS":
|
||||
response = app.response_class()
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
|
||||
response.headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization"
|
||||
return response
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from . import admin, auth, contact, monitoring, newsletter
|
||||
from . import admin, auth, contact, embed, monitoring, newsletter, static
|
||||
|
||||
|
||||
def register_blueprints(app: Flask) -> None:
|
||||
@@ -13,3 +13,5 @@ def register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(monitoring.bp)
|
||||
app.register_blueprint(auth.bp)
|
||||
app.register_blueprint(admin.bp)
|
||||
app.register_blueprint(embed.bp)
|
||||
app.register_blueprint(static.bp)
|
||||
|
||||
@@ -6,6 +6,17 @@ import logging
|
||||
|
||||
from .. import auth, settings
|
||||
from ..database import delete_app_setting, get_app_settings, get_subscribers, update_app_setting
|
||||
from ..services.email_settings import (
|
||||
EMAIL_SETTINGS_DEFINITIONS,
|
||||
load_email_settings,
|
||||
persist_email_settings,
|
||||
validate_email_settings,
|
||||
)
|
||||
from ..services.email_templates import (
|
||||
list_templates as list_email_templates,
|
||||
load_template as load_email_template,
|
||||
persist_template as persist_email_template,
|
||||
)
|
||||
|
||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
@@ -14,21 +25,21 @@ bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
@auth.login_required
|
||||
def dashboard():
|
||||
"""Display admin dashboard overview."""
|
||||
return render_template("admin_dashboard.html")
|
||||
return render_template("admin/admin_dashboard.html")
|
||||
|
||||
|
||||
@bp.route("/newsletter")
|
||||
@auth.login_required
|
||||
def newsletter_subscribers():
|
||||
"""Display newsletter subscriber management page."""
|
||||
return render_template("admin_newsletter.html")
|
||||
return render_template("admin/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")
|
||||
return render_template("admin/admin_newsletter_create.html")
|
||||
|
||||
|
||||
@bp.route("/settings")
|
||||
@@ -72,14 +83,35 @@ def settings_page():
|
||||
},
|
||||
}
|
||||
|
||||
return render_template("admin_settings.html", settings=app_settings)
|
||||
return render_template("admin/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")
|
||||
return render_template("admin/admin_submissions.html")
|
||||
|
||||
|
||||
@bp.route("/embeds")
|
||||
@auth.login_required
|
||||
def embeds():
|
||||
"""Display embeddable forms management page."""
|
||||
return render_template("admin/admin_embeds.html")
|
||||
|
||||
|
||||
@bp.route('/email-templates')
|
||||
@auth.login_required
|
||||
def email_templates():
|
||||
"""Display admin page for editing email templates."""
|
||||
return render_template('admin/admin_email_templates.html')
|
||||
|
||||
|
||||
@bp.route('/email-settings')
|
||||
@auth.login_required
|
||||
def email_settings_page():
|
||||
"""Render the dedicated email settings management page."""
|
||||
return render_template('admin/admin_email_settings.html')
|
||||
|
||||
|
||||
@bp.route("/api/settings", methods=["GET"])
|
||||
@@ -103,6 +135,11 @@ def validate_setting(key: str, value: str) -> str | None:
|
||||
"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,
|
||||
# Embed dimensions (pixels)
|
||||
"embed_contact_width": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
|
||||
"embed_contact_height": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
|
||||
"embed_newsletter_width": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
|
||||
"embed_newsletter_height": lambda v: v.isdigit() and 100 <= int(v) <= 2000,
|
||||
}
|
||||
|
||||
if key in validations and not validations[key](value):
|
||||
@@ -160,6 +197,121 @@ def delete_setting_api(key: str):
|
||||
return jsonify({"status": "error", "message": "Failed to delete setting."}), 500
|
||||
|
||||
|
||||
@bp.route("/api/email-settings", methods=["GET"])
|
||||
@auth.login_required
|
||||
def get_email_settings_api():
|
||||
"""Return the current email settings with field metadata."""
|
||||
try:
|
||||
data = load_email_settings()
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"settings": data,
|
||||
"schema": EMAIL_SETTINGS_DEFINITIONS,
|
||||
})
|
||||
except Exception as exc: # pragma: no cover - logged and surfaced to client
|
||||
logging.exception("Failed to load email settings: %s", exc)
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Failed to load email settings."
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route("/api/email-settings", methods=["PUT"])
|
||||
@auth.login_required
|
||||
def update_email_settings_api():
|
||||
"""Validate and persist email settings updates."""
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
errors = validate_email_settings(payload)
|
||||
if errors:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Email settings validation failed.",
|
||||
"errors": errors,
|
||||
}), 400
|
||||
|
||||
updated = persist_email_settings(payload)
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"message": "Email settings updated successfully.",
|
||||
"settings": updated,
|
||||
})
|
||||
except Exception as exc: # pragma: no cover - logged and surfaced to client
|
||||
logging.exception("Failed to update email settings: %s", exc)
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Failed to update email settings."
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route("/api/email-templates", methods=["GET"])
|
||||
@auth.login_required
|
||||
def list_email_templates_api():
|
||||
"""Return metadata for editable email templates."""
|
||||
try:
|
||||
templates = list_email_templates()
|
||||
return jsonify({"status": "ok", "templates": templates})
|
||||
except Exception as exc: # pragma: no cover
|
||||
logging.exception("Failed to list email templates: %s", exc)
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Failed to load email templates."
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route("/api/email-templates/<template_id>", methods=["GET"])
|
||||
@auth.login_required
|
||||
def get_email_template_api(template_id: str):
|
||||
"""Return the requested email template."""
|
||||
try:
|
||||
template = load_email_template(template_id)
|
||||
return jsonify({"status": "ok", "template": template})
|
||||
except KeyError:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Email template not found."
|
||||
}), 404
|
||||
except Exception as exc: # pragma: no cover
|
||||
logging.exception(
|
||||
"Failed to load email template %s: %s", template_id, exc)
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Failed to load email template."
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route("/api/email-templates/<template_id>", methods=["PUT"])
|
||||
@auth.login_required
|
||||
def update_email_template_api(template_id: str):
|
||||
"""Persist updates to an email template."""
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
content = payload.get("content", "")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Template content is required.",
|
||||
}), 400
|
||||
|
||||
updated = persist_email_template(template_id, content)
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"template": updated,
|
||||
})
|
||||
except KeyError:
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Email template not found."
|
||||
}), 404
|
||||
except Exception as exc: # pragma: no cover
|
||||
logging.exception(
|
||||
"Failed to update email template %s: %s", template_id, exc)
|
||||
return jsonify({
|
||||
"status": "error",
|
||||
"message": "Failed to update email template."
|
||||
}), 500
|
||||
|
||||
|
||||
@bp.route("/api/newsletter", methods=["GET"])
|
||||
@auth.login_required
|
||||
def get_subscribers_api():
|
||||
|
||||
18
server/routes/embed.py
Normal file
18
server/routes/embed.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Embeddable forms routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, render_template
|
||||
|
||||
bp = Blueprint("embed", __name__)
|
||||
|
||||
|
||||
@bp.route("/embed/contact", methods=["GET"])
|
||||
def contact_form():
|
||||
"""Serve the embeddable contact form."""
|
||||
return render_template("embed_contact.html")
|
||||
|
||||
|
||||
@bp.route("/embed/newsletter", methods=["GET"])
|
||||
def newsletter_form():
|
||||
"""Serve the embeddable newsletter subscription form."""
|
||||
return render_template("embed_newsletter.html")
|
||||
@@ -7,10 +7,10 @@ from flask import Blueprint, jsonify, request, render_template
|
||||
|
||||
from ..services import newsletter
|
||||
|
||||
bp = Blueprint("newsletter", __name__, url_prefix="/api")
|
||||
bp = Blueprint("newsletter", __name__)
|
||||
|
||||
|
||||
@bp.route("/newsletter", methods=["POST"])
|
||||
@bp.route("/api/newsletter", methods=["POST"])
|
||||
def subscribe():
|
||||
payload = request.form or request.get_json(silent=True) or {}
|
||||
email = (payload.get("email") or "").strip()
|
||||
@@ -29,10 +29,16 @@ def subscribe():
|
||||
return jsonify({"status": "error", "message": "Email is already subscribed."}), 409
|
||||
|
||||
logging.info("New newsletter subscription: %s", email)
|
||||
|
||||
# Send confirmation email
|
||||
email_sent = newsletter.send_subscription_confirmation(email)
|
||||
if not email_sent:
|
||||
logging.warning("Confirmation email not sent for %s", email)
|
||||
|
||||
return jsonify({"status": "ok", "message": "Subscribed successfully."}), 201
|
||||
|
||||
|
||||
@bp.route("/newsletter", methods=["DELETE"])
|
||||
@bp.route("/api/newsletter", methods=["DELETE"])
|
||||
def unsubscribe():
|
||||
payload = request.form or request.get_json(silent=True) or {}
|
||||
email = (payload.get("email") or "").strip()
|
||||
@@ -55,7 +61,7 @@ def unsubscribe():
|
||||
return jsonify({"status": "ok", "message": "Unsubscribed successfully."}), 200
|
||||
|
||||
|
||||
@bp.route("/newsletter", methods=["PUT"])
|
||||
@bp.route("/api/newsletter", methods=["PUT"])
|
||||
def update_subscription():
|
||||
payload = request.form or request.get_json(silent=True) or {}
|
||||
old_email = (payload.get("old_email") or "").strip()
|
||||
@@ -104,8 +110,7 @@ def manage_subscription():
|
||||
elif action == "unsubscribe":
|
||||
deleted = newsletter.unsubscribe(email)
|
||||
if deleted:
|
||||
message = "Successfully unsubscribed from newsletter."
|
||||
message_type = "success"
|
||||
return render_template("unsubscribe_confirmation.html")
|
||||
else:
|
||||
message = "This email is not currently subscribed."
|
||||
message_type = "info"
|
||||
@@ -131,3 +136,9 @@ def manage_subscription():
|
||||
message_type = "error"
|
||||
|
||||
return render_template("newsletter_manage.html", message=message, message_type=message_type)
|
||||
|
||||
|
||||
@bp.route("/newsletter/unsubscribe/confirmation", methods=["GET"])
|
||||
def unsubscribe_confirmation():
|
||||
"""Display newsletter unsubscription confirmation page."""
|
||||
return render_template("unsubscribe_confirmation.html")
|
||||
|
||||
27
server/routes/static.py
Normal file
27
server/routes/static.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, render_template, send_from_directory
|
||||
import os
|
||||
|
||||
bp = Blueprint("static", __name__)
|
||||
|
||||
|
||||
@bp.route("/static/css/styles.css", methods=["GET"])
|
||||
def serve_css():
|
||||
"""Serve the unified CSS file."""
|
||||
static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static')
|
||||
return send_from_directory(static_dir, 'css/styles.css')
|
||||
|
||||
|
||||
@bp.route("/static/css/admin.css", methods=["GET"])
|
||||
def serve_admin_css():
|
||||
"""Serve the unified CSS file."""
|
||||
static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static')
|
||||
return send_from_directory(static_dir, 'css/admin.css')
|
||||
|
||||
|
||||
@bp.route("/static/js/admin.js", methods=["GET"])
|
||||
def serve_admin_js():
|
||||
"""Serve the unified JS file."""
|
||||
static_dir = os.path.join(os.path.dirname(__file__), '..', '..', 'static')
|
||||
return send_from_directory(static_dir, 'js/admin.js')
|
||||
@@ -6,12 +6,12 @@ import smtplib
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from email.message import EmailMessage
|
||||
from typing import Any, Dict, Tuple
|
||||
from typing import Any, Dict, Tuple, cast
|
||||
|
||||
from .. import settings
|
||||
from ..database import save_contact
|
||||
from ..metrics import record_submission
|
||||
from ..utils import is_valid_email
|
||||
from .email_settings import load_effective_smtp_settings
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -70,12 +70,22 @@ def persist_submission(submission: ContactSubmission) -> int:
|
||||
|
||||
def send_notification(submission: ContactSubmission) -> bool:
|
||||
"""Send an email notification for the submission if SMTP is configured."""
|
||||
if not settings.SMTP_SETTINGS["host"] or not settings.SMTP_SETTINGS["recipients"]:
|
||||
smtp_config = load_effective_smtp_settings()
|
||||
|
||||
if not smtp_config.get("notify_contact_form"):
|
||||
logging.info("Contact notifications disabled; skipping email dispatch")
|
||||
return False
|
||||
|
||||
if not smtp_config.get("host") or not smtp_config.get("recipients"):
|
||||
logging.info("SMTP not configured; skipping email notification")
|
||||
return False
|
||||
|
||||
sender = settings.SMTP_SETTINGS["sender"] or "no-reply@example.com"
|
||||
recipients = settings.SMTP_SETTINGS["recipients"]
|
||||
sender = smtp_config.get("sender") or "no-reply@example.com"
|
||||
recipients = smtp_config.get("recipients", [])
|
||||
host = cast(str, smtp_config.get("host"))
|
||||
port = int(smtp_config.get("port") or 0)
|
||||
username = smtp_config.get("username") or None
|
||||
password = smtp_config.get("password") or ""
|
||||
|
||||
msg = EmailMessage()
|
||||
msg["Subject"] = f"Neue Kontaktanfrage von {submission.name}"
|
||||
@@ -98,12 +108,11 @@ def send_notification(submission: ContactSubmission) -> bool:
|
||||
)
|
||||
|
||||
try:
|
||||
with smtplib.SMTP(settings.SMTP_SETTINGS["host"], settings.SMTP_SETTINGS["port"], timeout=15) as server:
|
||||
if settings.SMTP_SETTINGS["use_tls"]:
|
||||
with smtplib.SMTP(host, port, timeout=15) as server:
|
||||
if smtp_config.get("use_tls"):
|
||||
server.starttls()
|
||||
if settings.SMTP_SETTINGS["username"]:
|
||||
server.login(
|
||||
settings.SMTP_SETTINGS["username"], settings.SMTP_SETTINGS["password"] or "")
|
||||
if username:
|
||||
server.login(username, password)
|
||||
server.send_message(msg)
|
||||
logging.info("Notification email dispatched to %s", recipients)
|
||||
return True
|
||||
|
||||
224
server/services/email_settings.py
Normal file
224
server/services/email_settings.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Email settings service helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
from ..database import get_app_settings, update_app_setting
|
||||
from ..settings import (
|
||||
SMTP_SETTINGS,
|
||||
EMAIL_NOTIFY_CONTACT_FORM,
|
||||
EMAIL_NOTIFY_NEWSLETTER_SIGNUPS,
|
||||
)
|
||||
from ..utils import normalize_recipients, is_valid_email
|
||||
|
||||
EMAIL_SETTING_PREFIX = "email_"
|
||||
|
||||
# Definition metadata describing each configurable field.
|
||||
EMAIL_SETTINGS_DEFINITIONS: Dict[str, Dict[str, Any]] = {
|
||||
"smtp_host": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"default": SMTP_SETTINGS.get("host") or "",
|
||||
"description": "Hostname or IP address of the SMTP server.",
|
||||
},
|
||||
"smtp_port": {
|
||||
"type": "int",
|
||||
"required": True,
|
||||
"default": int(SMTP_SETTINGS.get("port") or 587),
|
||||
"description": "Port number used to connect to the SMTP server.",
|
||||
},
|
||||
"smtp_username": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"default": SMTP_SETTINGS.get("username") or "",
|
||||
"description": "Username for authenticating with the SMTP server (optional).",
|
||||
},
|
||||
"smtp_password": {
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"default": SMTP_SETTINGS.get("password") or "",
|
||||
"description": "Password for authenticating with the SMTP server (optional).",
|
||||
},
|
||||
"smtp_sender": {
|
||||
"type": "string",
|
||||
"required": True,
|
||||
"default": SMTP_SETTINGS.get("sender") or SMTP_SETTINGS.get("username") or "",
|
||||
"description": "Default email address used in the From header when sending mail.",
|
||||
},
|
||||
"smtp_use_tls": {
|
||||
"type": "bool",
|
||||
"required": True,
|
||||
"default": bool(SMTP_SETTINGS.get("use_tls", True)),
|
||||
"description": "Enable TLS when communicating with the SMTP server.",
|
||||
},
|
||||
"smtp_recipients": {
|
||||
"type": "list",
|
||||
"required": False,
|
||||
"default": SMTP_SETTINGS.get("recipients") or [],
|
||||
"description": "Comma separated list of notification recipient emails.",
|
||||
},
|
||||
"notify_contact_form": {
|
||||
"type": "bool",
|
||||
"required": True,
|
||||
"default": EMAIL_NOTIFY_CONTACT_FORM,
|
||||
"description": "Send notification emails for new contact form submissions.",
|
||||
},
|
||||
"notify_newsletter_signups": {
|
||||
"type": "bool",
|
||||
"required": True,
|
||||
"default": EMAIL_NOTIFY_NEWSLETTER_SIGNUPS,
|
||||
"description": "Send notification emails for new newsletter subscriber signups.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _storage_key(field: str) -> str:
|
||||
"""Return the persistent storage key for a given field."""
|
||||
return f"{EMAIL_SETTING_PREFIX}{field}"
|
||||
|
||||
|
||||
def _ensure_bool(value: Any) -> bool:
|
||||
"""Coerce user-supplied values into boolean form."""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return False
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _ensure_int(value: Any) -> Tuple[bool, int | None]:
|
||||
"""Try to coerce a value into an integer."""
|
||||
try:
|
||||
return True, int(value)
|
||||
except (TypeError, ValueError):
|
||||
return False, None
|
||||
|
||||
|
||||
def _serialize(field: str, value: Any) -> str:
|
||||
"""Serialize a typed value to its string representation for storage."""
|
||||
definition = EMAIL_SETTINGS_DEFINITIONS[field]
|
||||
if definition["type"] == "bool":
|
||||
return "true" if bool(value) else "false"
|
||||
if definition["type"] == "int":
|
||||
return str(int(value))
|
||||
if definition["type"] == "list":
|
||||
if isinstance(value, str):
|
||||
value = normalize_recipients(value)
|
||||
return ", ".join(value)
|
||||
return str(value or "")
|
||||
|
||||
|
||||
def _deserialize(field: str, raw_value: Any) -> Any:
|
||||
"""Deserialize the stored string representation for a field."""
|
||||
definition = EMAIL_SETTINGS_DEFINITIONS[field]
|
||||
if raw_value is None or raw_value == "":
|
||||
return definition["default"]
|
||||
|
||||
if definition["type"] == "bool":
|
||||
return _ensure_bool(raw_value)
|
||||
if definition["type"] == "int":
|
||||
ok, coerced = _ensure_int(raw_value)
|
||||
return coerced if ok and coerced is not None else definition["default"]
|
||||
if definition["type"] == "list":
|
||||
if isinstance(raw_value, (list, tuple)):
|
||||
return [item for item in raw_value if item]
|
||||
return normalize_recipients(str(raw_value))
|
||||
return str(raw_value)
|
||||
|
||||
|
||||
def load_email_settings() -> Dict[str, Any]:
|
||||
"""Load email settings, combining stored overrides with defaults."""
|
||||
stored = get_app_settings()
|
||||
results: Dict[str, Any] = {}
|
||||
for field in EMAIL_SETTINGS_DEFINITIONS:
|
||||
raw_value = stored.get(_storage_key(field))
|
||||
results[field] = _deserialize(field, raw_value)
|
||||
return results
|
||||
|
||||
|
||||
def load_effective_smtp_settings() -> Dict[str, Any]:
|
||||
"""Return SMTP configuration merging persisted settings with environment defaults."""
|
||||
effective = load_email_settings()
|
||||
recipients_value = effective.get("smtp_recipients")
|
||||
if isinstance(recipients_value, list):
|
||||
effective_recipients = [item for item in recipients_value if item]
|
||||
else:
|
||||
effective_recipients = normalize_recipients(str(recipients_value or ""))
|
||||
|
||||
return {
|
||||
"host": effective.get("smtp_host") or SMTP_SETTINGS.get("host"),
|
||||
"port": effective.get("smtp_port") or SMTP_SETTINGS.get("port"),
|
||||
"username": effective.get("smtp_username") or SMTP_SETTINGS.get("username"),
|
||||
"password": effective.get("smtp_password") or SMTP_SETTINGS.get("password"),
|
||||
"sender": effective.get("smtp_sender") or SMTP_SETTINGS.get("sender") or SMTP_SETTINGS.get("username"),
|
||||
"use_tls": bool(effective.get("smtp_use_tls")) if effective.get("smtp_use_tls") is not None else SMTP_SETTINGS.get("use_tls", True),
|
||||
"recipients": effective_recipients,
|
||||
"notify_contact_form": bool(effective.get("notify_contact_form")),
|
||||
"notify_newsletter_signups": bool(effective.get("notify_newsletter_signups")),
|
||||
}
|
||||
|
||||
|
||||
def validate_email_settings(payload: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Validate user supplied email settings. Returns mapping of field -> error."""
|
||||
errors: Dict[str, str] = {}
|
||||
|
||||
smtp_host = str(payload.get("smtp_host", "")).strip()
|
||||
if not smtp_host:
|
||||
errors["smtp_host"] = "SMTP host is required."
|
||||
|
||||
ok, port_value = _ensure_int(payload.get("smtp_port"))
|
||||
if not ok or port_value is None or not (1 <= port_value <= 65535):
|
||||
errors["smtp_port"] = "SMTP port must be a valid integer between 1 and 65535."
|
||||
|
||||
sender = str(payload.get("smtp_sender", "")).strip()
|
||||
if not sender:
|
||||
errors["smtp_sender"] = "Sender email is required."
|
||||
elif not is_valid_email(sender):
|
||||
errors["smtp_sender"] = "Sender must be a valid email address."
|
||||
|
||||
recipients_raw = payload.get("smtp_recipients")
|
||||
if isinstance(recipients_raw, list):
|
||||
recipient_list = [item.strip() for item in recipients_raw if item]
|
||||
else:
|
||||
recipient_list = normalize_recipients(str(recipients_raw or ""))
|
||||
|
||||
if recipient_list:
|
||||
invalid = [addr for addr in recipient_list if not is_valid_email(addr)]
|
||||
if invalid:
|
||||
errors["smtp_recipients"] = "Invalid recipient email(s): " + ", ".join(
|
||||
invalid)
|
||||
|
||||
for flag_field in ("smtp_use_tls", "notify_contact_form", "notify_newsletter_signups"):
|
||||
value = payload.get(flag_field)
|
||||
if value is None:
|
||||
continue
|
||||
# Ensure value is interpretable as boolean.
|
||||
if not isinstance(value, bool) and str(value).strip().lower() not in {"1", "0", "true", "false", "yes", "no", "on", "off"}:
|
||||
errors[flag_field] = "Provide a boolean value."
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def persist_email_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Persist validated email settings and return the normalized payload."""
|
||||
normalized: Dict[str, Any] = {}
|
||||
for field, meta in EMAIL_SETTINGS_DEFINITIONS.items():
|
||||
value = payload.get(field, meta["default"])
|
||||
if meta["type"] == "bool":
|
||||
value = _ensure_bool(value)
|
||||
elif meta["type"] == "int":
|
||||
ok, coerced = _ensure_int(value)
|
||||
value = coerced if ok and coerced is not None else meta["default"]
|
||||
elif meta["type"] == "list":
|
||||
if isinstance(value, list):
|
||||
value = [item.strip() for item in value if item]
|
||||
else:
|
||||
value = normalize_recipients(str(value or ""))
|
||||
else:
|
||||
value = str(value or "").strip()
|
||||
|
||||
# Persist using string representation
|
||||
update_app_setting(_storage_key(field), _serialize(field, value))
|
||||
normalized[field] = value
|
||||
|
||||
return normalized
|
||||
86
server/services/email_templates.py
Normal file
86
server/services/email_templates.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Email template management helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List
|
||||
|
||||
from ..database import get_app_settings, update_app_setting
|
||||
from .. import settings
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EmailTemplateDefinition:
|
||||
"""Definition metadata describing an editable email template."""
|
||||
|
||||
id: str
|
||||
setting_key: str
|
||||
name: str
|
||||
description: str
|
||||
default_content: str
|
||||
|
||||
|
||||
EMAIL_TEMPLATE_DEFINITIONS: Dict[str, EmailTemplateDefinition] = {
|
||||
"newsletter_confirmation": EmailTemplateDefinition(
|
||||
id="newsletter_confirmation",
|
||||
setting_key="newsletter_confirmation_template",
|
||||
name="Newsletter Confirmation",
|
||||
description="HTML email sent to subscribers immediately after they confirm their newsletter signup.",
|
||||
default_content=getattr(
|
||||
settings, "NEWSLETTER_CONFIRMATION_TEMPLATE", "").strip(),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def list_templates() -> List[dict]:
|
||||
"""Return a list of metadata describing available email templates."""
|
||||
return [
|
||||
{
|
||||
"id": definition.id,
|
||||
"name": definition.name,
|
||||
"description": definition.description,
|
||||
}
|
||||
for definition in EMAIL_TEMPLATE_DEFINITIONS.values()
|
||||
]
|
||||
|
||||
|
||||
def _load_stored_templates() -> dict:
|
||||
"""Return stored template values keyed by their setting key."""
|
||||
return get_app_settings()
|
||||
|
||||
|
||||
def load_template(template_id: str) -> dict:
|
||||
"""Return template metadata and content for the requested template."""
|
||||
if template_id not in EMAIL_TEMPLATE_DEFINITIONS:
|
||||
raise KeyError(template_id)
|
||||
|
||||
definition = EMAIL_TEMPLATE_DEFINITIONS[template_id]
|
||||
stored = _load_stored_templates()
|
||||
content = stored.get(definition.setting_key, definition.default_content)
|
||||
|
||||
return {
|
||||
"id": definition.id,
|
||||
"name": definition.name,
|
||||
"description": definition.description,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
def persist_template(template_id: str, content: str) -> dict:
|
||||
"""Persist template content and return the updated template payload."""
|
||||
if template_id not in EMAIL_TEMPLATE_DEFINITIONS:
|
||||
raise KeyError(template_id)
|
||||
|
||||
definition = EMAIL_TEMPLATE_DEFINITIONS[template_id]
|
||||
content = (content or "").strip()
|
||||
update_app_setting(definition.setting_key, content)
|
||||
return load_template(template_id)
|
||||
|
||||
|
||||
def get_template_content(template_id: str) -> str:
|
||||
"""Return just the template body, falling back to defaults when unset."""
|
||||
if template_id not in EMAIL_TEMPLATE_DEFINITIONS:
|
||||
raise KeyError(template_id)
|
||||
|
||||
definition = EMAIL_TEMPLATE_DEFINITIONS[template_id]
|
||||
stored = _load_stored_templates()
|
||||
return stored.get(definition.setting_key, definition.default_content)
|
||||
@@ -2,9 +2,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import cast
|
||||
|
||||
from ..database import save_subscriber, delete_subscriber, update_subscriber
|
||||
from ..utils import is_valid_email
|
||||
from .email_settings import load_effective_smtp_settings
|
||||
from .email_templates import get_template_content
|
||||
|
||||
|
||||
def validate_email(email: str) -> bool:
|
||||
@@ -28,12 +31,73 @@ def update_email(old_email: str, new_email: str) -> bool:
|
||||
return update_subscriber(old_email, new_email)
|
||||
|
||||
|
||||
def send_subscription_confirmation(email: str) -> bool:
|
||||
"""Send a confirmation email to the subscriber."""
|
||||
import logging
|
||||
|
||||
smtp_config = load_effective_smtp_settings()
|
||||
if not smtp_config.get("notify_newsletter_signups"):
|
||||
logging.info(
|
||||
"Newsletter signup notifications disabled; skipping email")
|
||||
return False
|
||||
|
||||
host = smtp_config.get("host")
|
||||
port = int(smtp_config.get("port") or 0)
|
||||
username = smtp_config.get("username")
|
||||
password = smtp_config.get("password") or ""
|
||||
|
||||
if not host:
|
||||
logging.info("SMTP not configured; skipping confirmation email")
|
||||
return False
|
||||
|
||||
# Get template content (persisted or default)
|
||||
template = get_template_content("newsletter_confirmation")
|
||||
|
||||
try:
|
||||
import smtplib
|
||||
from email.mime.text import MIMEText
|
||||
from email.mime.multipart import MIMEMultipart
|
||||
|
||||
sender = smtp_config.get("sender") or "noreply@example.com"
|
||||
|
||||
# Create message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = "Newsletter Subscription Confirmation"
|
||||
msg['From'] = sender
|
||||
msg['To'] = email
|
||||
|
||||
# Add HTML content
|
||||
html_part = MIMEText(template, 'html')
|
||||
msg.attach(html_part)
|
||||
|
||||
# Send email
|
||||
with smtplib.SMTP(cast(str, host), port, timeout=15) as server:
|
||||
if smtp_config.get("use_tls"):
|
||||
server.starttls()
|
||||
if username:
|
||||
server.login(username, password)
|
||||
server.send_message(msg)
|
||||
|
||||
logging.info("Confirmation email sent to %s", email)
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
logging.exception(
|
||||
"Failed to send confirmation email to %s: %s", email, exc)
|
||||
return False
|
||||
|
||||
|
||||
def send_newsletter_to_subscribers(subject: str, content: str, emails: list[str], sender_name: str | None = None) -> int:
|
||||
"""Send newsletter to list of email addresses. Returns count of successful sends."""
|
||||
import logging
|
||||
from .. import settings
|
||||
smtp_config = load_effective_smtp_settings()
|
||||
|
||||
if not settings.SMTP_SETTINGS["host"]:
|
||||
host = smtp_config.get("host")
|
||||
port = int(smtp_config.get("port") or 0)
|
||||
username = smtp_config.get("username")
|
||||
password = smtp_config.get("password")
|
||||
|
||||
if not host:
|
||||
logging.error("SMTP not configured, cannot send newsletter")
|
||||
return 0
|
||||
|
||||
@@ -45,7 +109,7 @@ def send_newsletter_to_subscribers(subject: str, content: str, emails: list[str]
|
||||
# Create message
|
||||
msg = MIMEMultipart('alternative')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = settings.SMTP_SETTINGS["sender"] or "noreply@example.com"
|
||||
msg['From'] = smtp_config.get("sender") or "noreply@example.com"
|
||||
|
||||
# Format content
|
||||
formatted_content = content.replace('\n', '<br>')
|
||||
@@ -63,13 +127,12 @@ def send_newsletter_to_subscribers(subject: str, content: str, emails: list[str]
|
||||
|
||||
# Send to each recipient individually for better deliverability
|
||||
success_count = 0
|
||||
with smtplib.SMTP(settings.SMTP_SETTINGS["host"], settings.SMTP_SETTINGS["port"]) as server:
|
||||
if settings.SMTP_SETTINGS["use_tls"]:
|
||||
with smtplib.SMTP(cast(str, host), port) as server:
|
||||
if smtp_config.get("use_tls"):
|
||||
server.starttls()
|
||||
|
||||
if settings.SMTP_SETTINGS["username"] and settings.SMTP_SETTINGS["password"]:
|
||||
server.login(
|
||||
settings.SMTP_SETTINGS["username"], settings.SMTP_SETTINGS["password"])
|
||||
if username and password is not None:
|
||||
server.login(username, password)
|
||||
|
||||
for email in emails:
|
||||
try:
|
||||
|
||||
@@ -58,8 +58,27 @@ SMTP_SETTINGS = {
|
||||
"recipients": normalize_recipients(os.getenv("SMTP_RECIPIENTS")),
|
||||
}
|
||||
|
||||
EMAIL_NOTIFY_CONTACT_FORM = os.getenv("EMAIL_NOTIFY_CONTACT_FORM", "true").lower() in {
|
||||
"1", "true", "yes"}
|
||||
EMAIL_NOTIFY_NEWSLETTER_SIGNUPS = os.getenv("EMAIL_NOTIFY_NEWSLETTER_SIGNUPS", "false").lower() in {
|
||||
"1", "true", "yes"}
|
||||
|
||||
SMTP_SETTINGS["notify_contact_form"] = EMAIL_NOTIFY_CONTACT_FORM
|
||||
SMTP_SETTINGS["notify_newsletter_signups"] = EMAIL_NOTIFY_NEWSLETTER_SIGNUPS
|
||||
|
||||
if not SMTP_SETTINGS["sender"] and SMTP_SETTINGS["username"]:
|
||||
SMTP_SETTINGS["sender"] = SMTP_SETTINGS["username"]
|
||||
|
||||
ADMIN_USERNAME = os.getenv("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "admin")
|
||||
|
||||
NEWSLETTER_CONFIRMATION_TEMPLATE = os.getenv("NEWSLETTER_CONFIRMATION_TEMPLATE", """
|
||||
<html>
|
||||
<body>
|
||||
<h2>Welcome to our Newsletter!</h2>
|
||||
<p>Thank you for subscribing to our newsletter. You're now part of our community and will receive updates on our latest news and offers.</p>
|
||||
<p>If you wish to unsubscribe at any time, you can do so by visiting our <a href="https://your-domain.com/newsletter/manage">subscription management page</a>.</p>
|
||||
<p>Best regards,<br>The Team</p>
|
||||
</body>
|
||||
</html>
|
||||
""").strip()
|
||||
|
||||
388
static/css/admin.css
Normal file
388
static/css/admin.css
Normal file
@@ -0,0 +1,388 @@
|
||||
/* Navigation links */
|
||||
nav a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
margin-right: 20px;
|
||||
}
|
||||
nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.nav a {
|
||||
margin-right: 15px;
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.nav a.active {
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.pagination a,
|
||||
.pagination span {
|
||||
padding: 8px 12px;
|
||||
text-decoration: none;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.pagination a:hover {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pagination .current {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination button {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pagination button:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background-color: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pagination .current-page {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
/* Admin newsletter */
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
}
|
||||
.filters input,
|
||||
.filters select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
min-width: 150px;
|
||||
}
|
||||
.filters button {
|
||||
padding: 8px 16px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filters button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
/* Subscribers table */
|
||||
.subscribers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.subscribers-table th,
|
||||
.subscribers-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.subscribers-table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
.subscribers-table tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
/* Newsletter creation form */
|
||||
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.form-section h2 {
|
||||
margin-top: 0;
|
||||
color: #555;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.input-error {
|
||||
border-color: #dc3545;
|
||||
}
|
||||
.form-group textarea {
|
||||
min-height: 200px;
|
||||
resize: vertical;
|
||||
}
|
||||
.checkbox-inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: normal;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.newsletter-preview {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.newsletter-preview h3 {
|
||||
margin-top: 0;
|
||||
color: #555;
|
||||
}
|
||||
.newsletter-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Admin settings */
|
||||
.setting strong {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
}
|
||||
.iframe-code {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
font-family: monospace;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Admin settings cards grid */
|
||||
.settings-cards {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin: 8px 0 16px 0;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 8px;
|
||||
padding: 12px 14px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
|
||||
flex: 1 1 300px;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Keep individual dynamic setting layout readable */
|
||||
.setting {
|
||||
font-size: 0.95rem;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.settings-card {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Spinner styles used by embed pages */
|
||||
#spinnerIcon {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
#submitSpinner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Email templates layout */
|
||||
.email-templates-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.email-templates-list {
|
||||
flex: 0 0 280px;
|
||||
max-width: 320px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: #f8f9fa;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.template-item:hover {
|
||||
border-color: #007bff;
|
||||
background: #eef5ff;
|
||||
}
|
||||
|
||||
.template-item.active {
|
||||
border-color: #007bff;
|
||||
background: #e2ecff;
|
||||
}
|
||||
|
||||
.template-name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.template-description {
|
||||
margin: 0 0 12px 0;
|
||||
color: #555555;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.email-template-editor {
|
||||
flex: 1 1 380px;
|
||||
min-width: 300px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 8px;
|
||||
padding: 16px 18px;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.email-template-editor .form-group textarea {
|
||||
min-height: 360px;
|
||||
font-family: "Courier New", Courier, monospace;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
margin: 0;
|
||||
color: #555555;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.email-templates-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.email-templates-list {
|
||||
flex: 1 1 auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.email-template-editor {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
324
static/css/styles.css
Normal file
324
static/css/styles.css
Normal file
@@ -0,0 +1,324 @@
|
||||
/* Unified CSS Styles */
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f9f9f9;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #555;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Form styles */
|
||||
.form-container,
|
||||
.contact-form,
|
||||
.confirmation-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-group,
|
||||
.setting {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
height: 100px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
button,
|
||||
.btn {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button[disabled="true"],
|
||||
.btn[disabled="true"] {
|
||||
background-color: #cccccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
.btn:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
.btn-success:hover {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
|
||||
/* Message styles */
|
||||
.message {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
|
||||
.message.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
|
||||
/* Admin dashboard */
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background-color: #f9f9f9;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.dashboard-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.dashboard-card h2 {
|
||||
color: #555;
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.dashboard-card p {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: #e9ecef;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
min-width: 150px;
|
||||
margin: 5px;
|
||||
}
|
||||
.stat-card h3 {
|
||||
margin: 0;
|
||||
color: #495057;
|
||||
font-size: 2em;
|
||||
}
|
||||
.stat-card h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.stat-card .number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.stat-card p {
|
||||
margin: 5px 0 0 0;
|
||||
color: #6c757d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.logout {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.logout a {
|
||||
color: #dc3545;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.logout a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Admin settings */
|
||||
.setting-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.setting strong {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.settings-management {
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.setting-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.setting-info {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.setting-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Newsletter manage */
|
||||
.form-section {
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* Utility classes */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mt-20 {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.mb-20 {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
1267
static/js/admin.js
Normal file
1267
static/js/admin.js
Normal file
File diff suppressed because it is too large
Load Diff
15
templates/_base.html
Normal file
15
templates/_base.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}Newsletter Subscribers{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% include "_nav.html" %}
|
||||
<h1>{% block heading %}Newsletter Subscribers{% endblock %}</h1>
|
||||
{% block content %}{% endblock %}
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
43
templates/_nav.html
Normal file
43
templates/_nav.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<nav class="nav">
|
||||
<a
|
||||
href="{{ url_for('admin.dashboard') }}"
|
||||
class="nav-link{% if request.path in ['/admin', '/admin/'] %} active{% endif %}"
|
||||
>Dashboard</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('admin.submissions') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/submissions') %} active{% endif %}"
|
||||
>Contact Submissions</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('admin.newsletter_subscribers') }}"
|
||||
class="nav-link{% if request.path == '/admin/newsletter' %} active{% endif %}"
|
||||
>Subscribers</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('admin.newsletter_create') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/newsletter/create') %} active{% endif %}"
|
||||
>Create Newsletter</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('admin.embeds') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/embeds') %} active{% endif %}"
|
||||
>Embeds</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('admin.email_settings_page') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/email-settings') %} active{% endif %}"
|
||||
>Email Settings</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('admin.email_templates') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/email-templates') %} active{% endif %}"
|
||||
>Email Templates</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('admin.settings_page') }}"
|
||||
class="nav-link{% if request.path.startswith('/admin/settings') and not request.path.startswith('/admin/email-settings') %} active{% endif %}"
|
||||
>Settings</a
|
||||
>
|
||||
<a href="{{ url_for('auth.logout') }}" class="nav-link">Logout</a>
|
||||
</nav>
|
||||
68
templates/admin/admin_dashboard.html
Normal file
68
templates/admin/admin_dashboard.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "_base.html" %} {% block title %}Admin Dashboard{% endblock %} {%
|
||||
block heading %}Admin Dashboard{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
|
||||
content %}
|
||||
<div id="message"></div>
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<h3 id="contact-count">--</h3>
|
||||
<p>Contact Submissions</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 id="newsletter-count">--</h3>
|
||||
<p>Newsletter Subscribers</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 id="settings-count">--</h3>
|
||||
<p>App Settings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="dashboard-card">
|
||||
<h2>Contact Form Submissions</h2>
|
||||
<p>View and manage contact form submissions from your website visitors.</p>
|
||||
<a href="/admin/submissions">Manage Submissions</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Newsletter Subscribers</h2>
|
||||
<p>
|
||||
Manage newsletter subscriptions and send newsletters to your subscribers.
|
||||
</p>
|
||||
<a href="/admin/newsletter">Manage Subscribers</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Application Settings</h2>
|
||||
<p>Configure application settings and environment variables.</p>
|
||||
<a href="/admin/settings">Manage Settings</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Create Newsletter</h2>
|
||||
<p>Create and send newsletters to your subscribers.</p>
|
||||
<a href="/admin/newsletter/create">Create Newsletter</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Embeddable Forms</h2>
|
||||
<p>
|
||||
Instructions for embedding contact and newsletter forms on other websites.
|
||||
</p>
|
||||
<a href="/admin/embeds">View Embed Codes</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logout">
|
||||
<a href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
{% endblock %}{% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
<script>
|
||||
// Load stats when page loads
|
||||
if (typeof window.admin.loadDashboardStats === "function") {
|
||||
window.admin.loadDashboardStats();
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
81
templates/admin/admin_email_settings.html
Normal file
81
templates/admin/admin_email_settings.html
Normal file
@@ -0,0 +1,81 @@
|
||||
{% extends "_base.html" %}
|
||||
{% block title %}Email Settings{% endblock %}
|
||||
{% block heading %}Email Notification Settings{% endblock %}
|
||||
{% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" />
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="form-section">
|
||||
<h2>SMTP Configuration</h2>
|
||||
<p>
|
||||
Adjust the SMTP server configuration and notification preferences used for
|
||||
contact form alerts and newsletter messaging. Values saved here override the
|
||||
environment defaults documented in <code>.env</code>.
|
||||
</p>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<form id="emailSettingsForm" class="email-settings-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="smtpHost">SMTP Host</label>
|
||||
<input type="text" id="smtpHost" name="smtp_host" autocomplete="off" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtpPort">SMTP Port</label>
|
||||
<input type="number" id="smtpPort" name="smtp_port" min="1" max="65535" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="smtpUsername">SMTP Username</label>
|
||||
<input type="text" id="smtpUsername" name="smtp_username" autocomplete="username" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtpPassword">SMTP Password</label>
|
||||
<input type="password" id="smtpPassword" name="smtp_password" autocomplete="current-password" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="smtpSender">Sender Address</label>
|
||||
<input type="email" id="smtpSender" name="smtp_sender" autocomplete="email" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="smtpRecipients">Notification Recipients</label>
|
||||
<textarea id="smtpRecipients" name="smtp_recipients" rows="3" placeholder="comma-separated emails"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="smtpUseTls" name="smtp_use_tls" />
|
||||
Use TLS when sending mail
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="notifyContactForm" name="notify_contact_form" />
|
||||
Email notifications for new contact submissions
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="notifyNewsletter" name="notify_newsletter_signups" />
|
||||
Confirmation emails for newsletter signups
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-top: 20px;">
|
||||
<button type="submit" class="btn btn-primary">Save Email Settings</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
42
templates/admin/admin_email_templates.html
Normal file
42
templates/admin/admin_email_templates.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends "_base.html" %} {% block title %}Email Templates{% endblock %} {%
|
||||
block heading %}Email Templates{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" />
|
||||
{% endblock %} {% block content %}
|
||||
<div class="email-templates-container" id="emailTemplatesPage">
|
||||
<aside
|
||||
class="email-templates-list"
|
||||
id="emailTemplatesList"
|
||||
aria-label="Available email templates"
|
||||
></aside>
|
||||
|
||||
<section class="email-template-editor">
|
||||
<div id="templateMessage" class="message" style="display: none"></div>
|
||||
<h2 id="templateTitle">Select a template to start editing</h2>
|
||||
<p id="templateDescription" class="template-description"></p>
|
||||
|
||||
<form id="emailTemplateForm">
|
||||
<div class="form-group">
|
||||
<label for="templateContent">Template HTML</label>
|
||||
<textarea
|
||||
id="templateContent"
|
||||
rows="20"
|
||||
class="template-content"
|
||||
disabled
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="submit"
|
||||
id="saveTemplateButton"
|
||||
disabled
|
||||
>
|
||||
Save Template
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %} {% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
93
templates/admin/admin_embeds.html
Normal file
93
templates/admin/admin_embeds.html
Normal file
@@ -0,0 +1,93 @@
|
||||
{% extends "_base.html" %} {% block title %}Embeddable Forms{% endblock %} {%
|
||||
block heading %}Embeddable Forms{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
|
||||
content %}
|
||||
<div class="settings-management">
|
||||
<div id="contactForm" style="display: flex; justify-content: space-between">
|
||||
<div id="contactFormSettings" style="min-height: 300px">
|
||||
<h2>Contact Form</h2>
|
||||
<p>
|
||||
Use the following HTML code to embed the contact form on other websites:
|
||||
</p>
|
||||
<div class="embed-config">
|
||||
<label
|
||||
>Width: <input id="contactWidth" type="text" value="400"
|
||||
/></label>
|
||||
<label
|
||||
>Height: <input id="contactHeight" type="text" value="600"
|
||||
/></label>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="saveEmbedSetting('embed_contact_width','contactWidth')"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="saveEmbedSetting('embed_contact_height','contactHeight')"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<textarea id="iframeCode" class="iframe-code" readonly></textarea>
|
||||
<button class="btn btn-secondary" onclick="copyIframeCode()">
|
||||
Copy Contact Iframe
|
||||
</button>
|
||||
</div>
|
||||
<div id="contactFormPreview"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-management">
|
||||
<div
|
||||
id="newsletterForm"
|
||||
style="display: flex; justify-content: space-between"
|
||||
>
|
||||
<div id="newsletterFormSettings" style="min-height: 400px">
|
||||
<h2>Newsletter Subscription Form</h2>
|
||||
<p>
|
||||
Use the following HTML code to embed the newsletter subscription form on
|
||||
other websites:
|
||||
</p>
|
||||
<div class="embed-config">
|
||||
<label
|
||||
>Width: <input id="newsletterWidth" type="text" value="600"
|
||||
/></label>
|
||||
<label
|
||||
>Height: <input id="newsletterHeight" type="text" value="300"
|
||||
/></label>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="saveEmbedSetting('embed_newsletter_width','newsletterWidth')"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="saveEmbedSetting('embed_newsletter_height','newsletterHeight')"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="iframeNewsletterCode"
|
||||
class="iframe-code"
|
||||
readonly
|
||||
></textarea>
|
||||
<button class="btn btn-secondary" onclick="copyNewsletterIframeCode()">
|
||||
Copy Newsletter Iframe
|
||||
</button>
|
||||
</div>
|
||||
<div id="newsletterFormPreview"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-management">
|
||||
<p>
|
||||
Replace <code>http://your-server-domain</code> with your actual server
|
||||
domain and port (e.g., https://yourdomain.com).
|
||||
</p>
|
||||
</div>
|
||||
{% endblock %} {% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
</div>
|
||||
42
templates/admin/admin_newsletter.html
Normal file
42
templates/admin/admin_newsletter.html
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends "_base.html" %} {% block title %}Newsletter Subscribers{% endblock %}
|
||||
{% block heading %}Newsletter Subscribers{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
|
||||
content %}
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="filters">
|
||||
<input type="text" id="emailFilter" placeholder="Filter by email..." />
|
||||
<select id="sortBy">
|
||||
<option value="subscribed_at">Sort by Date</option>
|
||||
<option value="email">Sort by Email</option>
|
||||
</select>
|
||||
<select id="sortOrder">
|
||||
<option value="desc">Newest First</option>
|
||||
<option value="asc">Oldest First</option>
|
||||
</select>
|
||||
<button onclick="applyFilters()">Apply Filters</button>
|
||||
<button onclick="clearFilters()">Clear</button>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading">Loading subscribers...</div>
|
||||
<table id="subscribersTable" class="subscribers-table" style="display: none">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Subscribed Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="subscribersBody"></tbody>
|
||||
</table>
|
||||
|
||||
<div id="pagination" class="pagination" style="display: none">
|
||||
<button id="prevBtn" onclick="changePage(currentPage - 1)">Previous</button>
|
||||
<span id="pageInfo"></span>
|
||||
<button id="nextBtn" onclick="changePage(currentPage + 1)">Next</button>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
98
templates/admin/admin_newsletter_create.html
Normal file
98
templates/admin/admin_newsletter_create.html
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "_base.html" %} {% block title %}Create Newsletter{% endblock %} {%
|
||||
block heading %}Create Newsletter{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" /> {% endblock %} {% block
|
||||
content %}
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="newsletter-stats">
|
||||
<div class="stat-card">
|
||||
<h4>Total Subscribers</h4>
|
||||
<div class="number" id="totalSubscribers">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h4>Active Subscribers</h4>
|
||||
<div class="number" id="activeSubscribers">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h4>Last Sent</h4>
|
||||
<div class="number" id="lastSent">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="newsletterForm">
|
||||
<div class="form-section">
|
||||
<h2>Newsletter Details</h2>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subject">Subject Line *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
required
|
||||
placeholder="Enter newsletter subject"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="senderName">Sender Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="senderName"
|
||||
name="sender_name"
|
||||
placeholder="Your Name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">Content *</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
required
|
||||
placeholder="Write your newsletter content here..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sendDate">Send Date (optional)</label>
|
||||
<input type="datetime-local" id="sendDate" name="send_date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="sent">Sent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Actions</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick="previewNewsletter()"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveDraft()">
|
||||
Save Draft
|
||||
</button>
|
||||
<button type="button" class="btn btn-success" onclick="sendNewsletter()">
|
||||
Send Newsletter
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" onclick="clearForm()">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="previewSection" class="newsletter-preview hidden">
|
||||
<h3>Newsletter Preview</h3>
|
||||
<div id="previewContent"></div>
|
||||
</div>
|
||||
{% endblock %}{% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
59
templates/admin/admin_settings.html
Normal file
59
templates/admin/admin_settings.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% extends "_base.html" %} {% block title %}Settings{% endblock %} {% block
|
||||
heading %}Application Settings{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" />
|
||||
{% endblock %} {% block content %}
|
||||
<div class="settings-cards">
|
||||
{% for category, category_settings in settings.items() %}
|
||||
<div class="settings-card">
|
||||
<h2 class="card-title">{{ category }}</h2>
|
||||
<div class="card-body">
|
||||
{% for key, value in category_settings.items() %}
|
||||
<div class="setting"><strong>{{ key }}:</strong> {{ value }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="settings-management">
|
||||
<h2>Dynamic Settings Management</h2>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="settings-list" id="settingsList">
|
||||
<p>Loading settings...</p>
|
||||
</div>
|
||||
|
||||
<h3>Add New Setting</h3>
|
||||
<form id="addSettingForm">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="newKey">Setting Key:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newKey"
|
||||
name="key"
|
||||
required
|
||||
placeholder="e.g., maintenance_mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newValue">Setting Value:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newValue"
|
||||
name="value"
|
||||
required
|
||||
placeholder="e.g., false"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Add Setting</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %} {% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
80
templates/admin/admin_submissions.html
Normal file
80
templates/admin/admin_submissions.html
Normal file
@@ -0,0 +1,80 @@
|
||||
{% extends "_base.html" %} {% block title %}Contact Submissions{% endblock %} {%
|
||||
block heading %}Contact Form Submissions{% endblock %} {% block extra_styles %}
|
||||
<link rel="stylesheet" href="/static/css/admin.css" />
|
||||
<style>
|
||||
.filters {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filters form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
}
|
||||
</style>
|
||||
{% endblock %} {% block content %}
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="filters">
|
||||
<form id="filterForm">
|
||||
<div>
|
||||
<label for="email">Email Filter:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="Filter by email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_from">Date From:</label>
|
||||
<input type="date" id="date_from" name="date_from" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_to">Date To:</label>
|
||||
<input type="date" id="date_to" name="date_to" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="per_page">Items per page:</label>
|
||||
<select id="per_page" name="per_page">
|
||||
<option value="25">25</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit">Apply Filters</button>
|
||||
<button type="button" class="clear-btn" onclick="clearFilters()">
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading" style="display: none">Loading...</div>
|
||||
|
||||
<table id="submissionsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="id">ID</th>
|
||||
<th data-sort="name">Name</th>
|
||||
<th data-sort="email">Email</th>
|
||||
<th data-sort="company">Company</th>
|
||||
<th>Message</th>
|
||||
<th data-sort="created_at">Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissionsBody">
|
||||
<tr>
|
||||
<td colspan="7" class="no-data">Loading submissions...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination" id="pagination"></div>
|
||||
|
||||
{% endblock %} {% block extra_scripts %}
|
||||
<script src="/static/js/admin.js"></script>
|
||||
{% endblock %}
|
||||
@@ -1,188 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Dashboard</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
.dashboard-card {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
background-color: #f9f9f9;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
.dashboard-card:hover {
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.dashboard-card h2 {
|
||||
color: #555;
|
||||
margin-top: 0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.dashboard-card p {
|
||||
color: #666;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.dashboard-card a {
|
||||
display: inline-block;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
.dashboard-card a:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.logout {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
}
|
||||
.logout a {
|
||||
color: #dc3545;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logout a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 30px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat-card {
|
||||
background-color: #e9ecef;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
min-width: 150px;
|
||||
margin: 5px;
|
||||
}
|
||||
.stat-card h3 {
|
||||
margin: 0;
|
||||
color: #495057;
|
||||
font-size: 2em;
|
||||
}
|
||||
.stat-card p {
|
||||
margin: 5px 0 0 0;
|
||||
color: #6c757d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Admin Dashboard</h1>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<h3 id="contact-count">--</h3>
|
||||
<p>Contact Submissions</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 id="newsletter-count">--</h3>
|
||||
<p>Newsletter Subscribers</p>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h3 id="settings-count">--</h3>
|
||||
<p>App Settings</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="dashboard-card">
|
||||
<h2>Contact Form Submissions</h2>
|
||||
<p>
|
||||
View and manage contact form submissions from your website visitors.
|
||||
</p>
|
||||
<a href="/admin/submissions">Manage Submissions</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Newsletter Subscribers</h2>
|
||||
<p>
|
||||
Manage newsletter subscriptions and send newsletters to your
|
||||
subscribers.
|
||||
</p>
|
||||
<a href="/admin/newsletter">Manage Subscribers</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Application Settings</h2>
|
||||
<p>Configure application settings and environment variables.</p>
|
||||
<a href="/admin/settings">Manage Settings</a>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-card">
|
||||
<h2>Create Newsletter</h2>
|
||||
<p>Create and send newsletters to your subscribers.</p>
|
||||
<a href="/admin/newsletter/create">Create Newsletter</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="logout">
|
||||
<a href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Load dashboard statistics
|
||||
async function loadStats() {
|
||||
try {
|
||||
// Load contact submissions count
|
||||
const contactResponse = await fetch(
|
||||
"/admin/api/contact?page=1&per_page=1"
|
||||
);
|
||||
if (contactResponse.ok) {
|
||||
const contactData = await contactResponse.json();
|
||||
document.getElementById("contact-count").textContent =
|
||||
contactData.pagination.total;
|
||||
}
|
||||
|
||||
// Load newsletter subscribers count
|
||||
const newsletterResponse = await fetch(
|
||||
"/admin/api/newsletter?page=1&per_page=1"
|
||||
);
|
||||
if (newsletterResponse.ok) {
|
||||
const newsletterData = await newsletterResponse.json();
|
||||
document.getElementById("newsletter-count").textContent =
|
||||
newsletterData.pagination.total;
|
||||
}
|
||||
|
||||
// Load settings count
|
||||
const settingsResponse = await fetch("/admin/api/settings");
|
||||
if (settingsResponse.ok) {
|
||||
const settingsData = await settingsResponse.json();
|
||||
document.getElementById("settings-count").textContent = Object.keys(
|
||||
settingsData.settings
|
||||
).length;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load dashboard stats:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load stats when page loads
|
||||
loadStats();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,363 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Newsletter Subscribers</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.nav a {
|
||||
margin-right: 15px;
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.filters {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.filters input,
|
||||
.filters select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
min-width: 150px;
|
||||
}
|
||||
.filters button {
|
||||
padding: 8px 16px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filters button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.subscribers-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.subscribers-table th,
|
||||
.subscribers-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
.subscribers-table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
.subscribers-table tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
.pagination button {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.pagination button:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
background-color: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pagination .current-page {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
.message {
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
.btn {
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/admin/">Dashboard</a>
|
||||
<a href="/admin/submissions">Contact Submissions</a>
|
||||
<a href="/admin/settings">Settings</a>
|
||||
<a href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<h1>Newsletter Subscribers</h1>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="filters">
|
||||
<input type="text" id="emailFilter" placeholder="Filter by email..." />
|
||||
<select id="sortBy">
|
||||
<option value="subscribed_at">Sort by Date</option>
|
||||
<option value="email">Sort by Email</option>
|
||||
</select>
|
||||
<select id="sortOrder">
|
||||
<option value="desc">Newest First</option>
|
||||
<option value="asc">Oldest First</option>
|
||||
</select>
|
||||
<button onclick="applyFilters()">Apply Filters</button>
|
||||
<button onclick="clearFilters()">Clear</button>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading">Loading subscribers...</div>
|
||||
<table
|
||||
id="subscribersTable"
|
||||
class="subscribers-table"
|
||||
style="display: none"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Subscribed Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="subscribersBody"></tbody>
|
||||
</table>
|
||||
|
||||
<div id="pagination" class="pagination" style="display: none">
|
||||
<button id="prevBtn" onclick="changePage(currentPage - 1)">
|
||||
Previous
|
||||
</button>
|
||||
<span id="pageInfo"></span>
|
||||
<button id="nextBtn" onclick="changePage(currentPage + 1)">Next</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let currentFilters = {
|
||||
email: "",
|
||||
sort_by: "subscribed_at",
|
||||
sort_order: "desc",
|
||||
};
|
||||
|
||||
// Load subscribers on page load
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadSubscribers();
|
||||
});
|
||||
|
||||
function applyFilters() {
|
||||
currentFilters.email = document
|
||||
.getElementById("emailFilter")
|
||||
.value.trim();
|
||||
currentFilters.sort_by = document.getElementById("sortBy").value;
|
||||
currentFilters.sort_order = document.getElementById("sortOrder").value;
|
||||
currentPage = 1;
|
||||
loadSubscribers();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
document.getElementById("emailFilter").value = "";
|
||||
document.getElementById("sortBy").value = "subscribed_at";
|
||||
document.getElementById("sortOrder").value = "desc";
|
||||
currentFilters = {
|
||||
email: "",
|
||||
sort_by: "subscribed_at",
|
||||
sort_order: "desc",
|
||||
};
|
||||
currentPage = 1;
|
||||
loadSubscribers();
|
||||
}
|
||||
|
||||
function loadSubscribers() {
|
||||
document.getElementById("loading").style.display = "block";
|
||||
document.getElementById("subscribersTable").style.display = "none";
|
||||
document.getElementById("pagination").style.display = "none";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage,
|
||||
per_page: 50,
|
||||
sort_by: currentFilters.sort_by,
|
||||
sort_order: currentFilters.sort_order,
|
||||
});
|
||||
|
||||
if (currentFilters.email) {
|
||||
params.append("email", currentFilters.email);
|
||||
}
|
||||
|
||||
fetch(`/admin/api/newsletter?${params}`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
displaySubscribers(data.subscribers);
|
||||
updatePagination(data.pagination);
|
||||
} else {
|
||||
showMessage(
|
||||
"Error loading subscribers: " +
|
||||
(data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error loading subscribers", "error");
|
||||
})
|
||||
.finally(() => {
|
||||
document.getElementById("loading").style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
function displaySubscribers(subscribers) {
|
||||
const tbody = document.getElementById("subscribersBody");
|
||||
tbody.innerHTML = "";
|
||||
|
||||
if (subscribers.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="3" style="text-align: center; padding: 40px; color: #666;">No subscribers found</td></tr>';
|
||||
} else {
|
||||
subscribers.forEach((subscriber) => {
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${escapeHtml(subscriber.email)}</td>
|
||||
<td>${new Date(
|
||||
subscriber.subscribed_at
|
||||
).toLocaleDateString()}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-danger" onclick="unsubscribe('${escapeHtml(
|
||||
subscriber.email
|
||||
)}')">Unsubscribe</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("subscribersTable").style.display = "table";
|
||||
}
|
||||
|
||||
function updatePagination(pagination) {
|
||||
const pageInfo = document.getElementById("pageInfo");
|
||||
const prevBtn = document.getElementById("prevBtn");
|
||||
const nextBtn = document.getElementById("nextBtn");
|
||||
|
||||
pageInfo.textContent = `Page ${pagination.page} of ${pagination.pages} (${pagination.total} total)`;
|
||||
|
||||
prevBtn.disabled = pagination.page <= 1;
|
||||
nextBtn.disabled = pagination.page >= pagination.pages;
|
||||
|
||||
document.getElementById("pagination").style.display = "flex";
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
currentPage = page;
|
||||
loadSubscribers();
|
||||
}
|
||||
|
||||
function unsubscribe(email) {
|
||||
if (!confirm(`Are you sure you want to unsubscribe ${email}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/newsletter", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ email: email }),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
showMessage("Subscriber unsubscribed successfully", "success");
|
||||
loadSubscribers(); // Reload the list
|
||||
} else {
|
||||
showMessage(
|
||||
"Error unsubscribing: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error unsubscribing subscriber", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageDiv = document.getElementById("message");
|
||||
messageDiv.className = `message ${type}`;
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.style.display = "block";
|
||||
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,459 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Create Newsletter</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.nav a {
|
||||
margin-right: 15px;
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 30px;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
.form-section h2 {
|
||||
margin-top: 0;
|
||||
color: #555;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-group textarea {
|
||||
min-height: 200px;
|
||||
resize: vertical;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
.btn-success:hover {
|
||||
background-color: #1e7e34;
|
||||
}
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
.message {
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.message.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
.newsletter-preview {
|
||||
margin-top: 20px;
|
||||
padding: 20px;
|
||||
background-color: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.newsletter-preview h3 {
|
||||
margin-top: 0;
|
||||
color: #555;
|
||||
}
|
||||
.newsletter-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.stat-card {
|
||||
background-color: white;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #ddd;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-card h4 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.stat-card .number {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/admin/">Dashboard</a>
|
||||
<a href="/admin/newsletter">Subscribers</a>
|
||||
<a href="/admin/settings">Settings</a>
|
||||
<a href="/auth/logout">Logout</a>
|
||||
</div>
|
||||
|
||||
<h1>Create Newsletter</h1>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="newsletter-stats">
|
||||
<div class="stat-card">
|
||||
<h4>Total Subscribers</h4>
|
||||
<div class="number" id="totalSubscribers">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h4>Active Subscribers</h4>
|
||||
<div class="number" id="activeSubscribers">--</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<h4>Last Sent</h4>
|
||||
<div class="number" id="lastSent">--</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="newsletterForm">
|
||||
<div class="form-section">
|
||||
<h2>Newsletter Details</h2>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="subject">Subject Line *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
required
|
||||
placeholder="Enter newsletter subject"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="senderName">Sender Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="senderName"
|
||||
name="sender_name"
|
||||
placeholder="Your Name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="content">Content *</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
required
|
||||
placeholder="Write your newsletter content here..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="sendDate">Send Date (optional)</label>
|
||||
<input type="datetime-local" id="sendDate" name="send_date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="scheduled">Scheduled</option>
|
||||
<option value="sent">Sent</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h2>Actions</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick="previewNewsletter()"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveDraft()">
|
||||
Save Draft
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick="sendNewsletter()"
|
||||
>
|
||||
Send Newsletter
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" onclick="clearForm()">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="previewSection" class="newsletter-preview hidden">
|
||||
<h3>Newsletter Preview</h3>
|
||||
<div id="previewContent"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let newsletterStats = {};
|
||||
|
||||
// Load newsletter stats on page load
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadNewsletterStats();
|
||||
});
|
||||
|
||||
function loadNewsletterStats() {
|
||||
// Load subscriber count
|
||||
fetch("/admin/api/newsletter?page=1&per_page=1")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
document.getElementById("totalSubscribers").textContent =
|
||||
data.pagination.total;
|
||||
document.getElementById("activeSubscribers").textContent =
|
||||
data.pagination.total; // For now, assume all are active
|
||||
newsletterStats.totalSubscribers = data.pagination.total;
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error loading subscriber stats:", error);
|
||||
});
|
||||
|
||||
// For now, set last sent as N/A
|
||||
document.getElementById("lastSent").textContent = "N/A";
|
||||
}
|
||||
|
||||
function previewNewsletter() {
|
||||
const subject = document.getElementById("subject").value.trim();
|
||||
const content = document.getElementById("content").value.trim();
|
||||
const senderName = document.getElementById("senderName").value.trim();
|
||||
|
||||
if (!subject || !content) {
|
||||
showMessage("Subject and content are required for preview.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const previewContent = document.getElementById("previewContent");
|
||||
previewContent.innerHTML = `
|
||||
<h2>${escapeHtml(subject)}</h2>
|
||||
${
|
||||
senderName
|
||||
? `<p><strong>From:</strong> ${escapeHtml(senderName)}</p>`
|
||||
: ""
|
||||
}
|
||||
<div style="margin-top: 20px; line-height: 1.6;">
|
||||
${content.replace(/\n/g, "<br>")}
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById("previewSection").classList.remove("hidden");
|
||||
showMessage("Newsletter preview generated.", "info");
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
const formData = new FormData(document.getElementById('newsletterForm'));
|
||||
const newsletterData = {
|
||||
subject: formData.get('subject'),
|
||||
content: formData.get('content'),
|
||||
sender_name: formData.get('sender_name'),
|
||||
send_date: formData.get('send_date'),
|
||||
status: 'draft'
|
||||
};
|
||||
|
||||
if (!newsletterData.subject || !newsletterData.content) {
|
||||
showMessage('Subject and content are required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/admin/api/newsletters', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newsletterData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
showMessage('Newsletter draft saved successfully!', 'success');
|
||||
} else {
|
||||
showMessage(data.message || 'Failed to save draft.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error saving draft:', error);
|
||||
showMessage('Failed to save draft.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function sendNewsletter() {
|
||||
const formData = new FormData(document.getElementById('newsletterForm'));
|
||||
const newsletterData = {
|
||||
subject: formData.get('subject'),
|
||||
content: formData.get('content'),
|
||||
sender_name: formData.get('sender_name'),
|
||||
send_date: formData.get('send_date'),
|
||||
status: 'sent'
|
||||
};
|
||||
|
||||
if (!newsletterData.subject || !newsletterData.content) {
|
||||
showMessage('Subject and content are required.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to send this newsletter to ${newsletterStats.totalSubscribers || 0} subscribers?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// First save the newsletter
|
||||
fetch('/admin/api/newsletters', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(newsletterData)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
const newsletterId = data.newsletter_id;
|
||||
// Now send it
|
||||
return fetch(`/admin/api/newsletters/${newsletterId}/send`, {
|
||||
method: 'POST'
|
||||
});
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to save newsletter.');
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.status === 'ok') {
|
||||
showMessage(`Newsletter sent successfully to ${data.sent_count} subscribers!`, 'success');
|
||||
} else {
|
||||
showMessage(data.message || 'Failed to send newsletter.', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error sending newsletter:', error);
|
||||
showMessage('Failed to send newsletter.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function clearForm() {
|
||||
if (
|
||||
confirm(
|
||||
"Are you sure you want to clear the form? All unsaved changes will be lost."
|
||||
)
|
||||
) {
|
||||
document.getElementById("newsletterForm").reset();
|
||||
document.getElementById("previewSection").classList.add("hidden");
|
||||
showMessage("Form cleared.", "info");
|
||||
}
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageDiv = document.getElementById("message");
|
||||
messageDiv.className = `message ${type}`;
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.style.display = "block";
|
||||
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,422 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Settings</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
}
|
||||
h2 {
|
||||
color: #555;
|
||||
border-bottom: 1px solid #ddd;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
.setting-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.setting {
|
||||
margin: 5px 0;
|
||||
}
|
||||
.setting strong {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
}
|
||||
.logout {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.logout a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.logout a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.settings-management {
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.settings-list {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.setting-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.setting-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.setting-info {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.setting-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.btn {
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.btn-danger {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
.btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
.btn:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.message {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.message.success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.message.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.edit-form {
|
||||
display: none;
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 3px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="logout">
|
||||
<a
|
||||
href="/admin/"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>Dashboard</a
|
||||
>
|
||||
<a
|
||||
href="/admin/submissions"
|
||||
style="color: #007bff; text-decoration: none; margin-right: 20px"
|
||||
>View Submissions</a
|
||||
>
|
||||
<a
|
||||
href="{{ url_for('auth.logout') }}"
|
||||
style="color: #007bff; text-decoration: none"
|
||||
>Logout</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<h1>Application Settings</h1>
|
||||
|
||||
{% for category, category_settings in settings.items() %}
|
||||
<div class="setting-group">
|
||||
<h2>{{ category }}</h2>
|
||||
{% for key, value in category_settings.items() %}
|
||||
<div class="setting"><strong>{{ key }}:</strong> {{ value }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="settings-management">
|
||||
<h2>Dynamic Settings Management</h2>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="settings-list" id="settingsList">
|
||||
<p>Loading settings...</p>
|
||||
</div>
|
||||
|
||||
<h3>Add New Setting</h3>
|
||||
<form id="addSettingForm">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="newKey">Setting Key:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newKey"
|
||||
name="key"
|
||||
required
|
||||
placeholder="e.g., maintenance_mode"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newValue">Setting Value:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="newValue"
|
||||
name="value"
|
||||
required
|
||||
placeholder="e.g., false"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button type="submit" class="btn btn-primary">Add Setting</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let appSettings = {};
|
||||
|
||||
// Load settings on page load
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadSettings();
|
||||
});
|
||||
|
||||
// Handle add setting form
|
||||
document
|
||||
.getElementById("addSettingForm")
|
||||
.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
const key = formData.get("key").trim();
|
||||
const value = formData.get("value").trim();
|
||||
|
||||
if (!key || !value) {
|
||||
showMessage("Both key and value are required.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
addSetting(key, value);
|
||||
});
|
||||
|
||||
function loadSettings() {
|
||||
fetch("/admin/api/settings")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
appSettings = data.settings;
|
||||
displaySettings();
|
||||
} else {
|
||||
showMessage(
|
||||
"Error loading settings: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error loading settings", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function displaySettings() {
|
||||
const container = document.getElementById("settingsList");
|
||||
|
||||
if (Object.keys(appSettings).length === 0) {
|
||||
container.innerHTML = "<p>No dynamic settings configured.</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
const settingsHtml = Object.entries(appSettings)
|
||||
.map(
|
||||
([key, value]) => `
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<strong>${escapeHtml(key)}:</strong> ${escapeHtml(value)}
|
||||
</div>
|
||||
<div class="setting-actions">
|
||||
<button class="btn btn-secondary" onclick="editSetting('${escapeHtml(
|
||||
key
|
||||
)}')">Edit</button>
|
||||
<button class="btn btn-danger" onclick="deleteSetting('${escapeHtml(
|
||||
key
|
||||
)}')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="edit-form" id="edit-${escapeHtml(key)}">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Key:</label>
|
||||
<input type="text" value="${escapeHtml(key)}" readonly>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>New Value:</label>
|
||||
<input type="text" id="edit-value-${escapeHtml(
|
||||
key
|
||||
)}" value="${escapeHtml(value)}" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<button class="btn btn-primary" onclick="updateSetting('${escapeHtml(
|
||||
key
|
||||
)}')">Update</button>
|
||||
<button class="btn btn-secondary" onclick="cancelEdit('${escapeHtml(
|
||||
key
|
||||
)}')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
container.innerHTML = settingsHtml;
|
||||
}
|
||||
|
||||
function addSetting(key, value) {
|
||||
fetch(`/admin/api/settings/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ value: value }),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
appSettings[key] = value;
|
||||
displaySettings();
|
||||
document.getElementById("addSettingForm").reset();
|
||||
showMessage("Setting added successfully!", "success");
|
||||
} else {
|
||||
showMessage(
|
||||
"Error adding setting: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error adding setting", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function editSetting(key) {
|
||||
document.getElementById(`edit-${key}`).style.display = "block";
|
||||
}
|
||||
|
||||
function cancelEdit(key) {
|
||||
document.getElementById(`edit-${key}`).style.display = "none";
|
||||
}
|
||||
|
||||
function updateSetting(key) {
|
||||
const newValue = document
|
||||
.getElementById(`edit-value-${key}`)
|
||||
.value.trim();
|
||||
|
||||
if (!newValue) {
|
||||
showMessage("Value cannot be empty.", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/admin/api/settings/${encodeURIComponent(key)}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ value: newValue }),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
appSettings[key] = newValue;
|
||||
displaySettings();
|
||||
cancelEdit(key);
|
||||
showMessage("Setting updated successfully!", "success");
|
||||
} else {
|
||||
showMessage(
|
||||
"Error updating setting: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error updating setting", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function deleteSetting(key) {
|
||||
if (!confirm(`Are you sure you want to delete the setting "${key}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/admin/api/settings/${encodeURIComponent(key)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
delete appSettings[key];
|
||||
displaySettings();
|
||||
showMessage("Setting deleted successfully!", "success");
|
||||
} else {
|
||||
showMessage(
|
||||
"Error deleting setting: " + (data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error deleting setting", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageDiv = document.getElementById("message");
|
||||
messageDiv.className = `message ${type}`;
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.style.display = "block";
|
||||
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,437 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Contact Submissions</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.nav {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.nav a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
margin-right: 20px;
|
||||
}
|
||||
.nav a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.filters {
|
||||
background: #f8f9fa;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filters form {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
}
|
||||
.filters label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.filters input,
|
||||
.filters select {
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.filters button {
|
||||
padding: 8px 15px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.filters button:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
.filters .clear-btn {
|
||||
background: #6c757d;
|
||||
}
|
||||
.filters .clear-btn:hover {
|
||||
background: #545b62;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
th:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
th.sort-asc::after {
|
||||
content: " ↑";
|
||||
}
|
||||
th.sort-desc::after {
|
||||
content: " ↓";
|
||||
}
|
||||
tr:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.message {
|
||||
padding: 8px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.pagination {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.pagination button {
|
||||
padding: 8px 12px;
|
||||
margin: 0 2px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
.pagination button:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.pagination button.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
.pagination button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.delete-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.delete-btn:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.no-data {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
.submission-details {
|
||||
max-width: 300px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="nav">
|
||||
<a href="/admin/">Dashboard</a>
|
||||
<a href="/admin/settings">Settings</a>
|
||||
<a href="{{ url_for('auth.logout') }}">Logout</a>
|
||||
</div>
|
||||
|
||||
<h1>Contact Form Submissions</h1>
|
||||
|
||||
<div id="message"></div>
|
||||
|
||||
<div class="filters">
|
||||
<form id="filterForm">
|
||||
<div>
|
||||
<label for="email">Email Filter:</label>
|
||||
<input
|
||||
type="text"
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="Filter by email"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_from">Date From:</label>
|
||||
<input type="date" id="date_from" name="date_from" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="date_to">Date To:</label>
|
||||
<input type="date" id="date_to" name="date_to" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="per_page">Items per page:</label>
|
||||
<select id="per_page" name="per_page">
|
||||
<option value="25">25</option>
|
||||
<option value="50" selected>50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit">Apply Filters</button>
|
||||
<button type="button" class="clear-btn" onclick="clearFilters()">
|
||||
Clear
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading" style="display: none">Loading...</div>
|
||||
|
||||
<table id="submissionsTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="id">ID</th>
|
||||
<th data-sort="name">Name</th>
|
||||
<th data-sort="email">Email</th>
|
||||
<th data-sort="company">Company</th>
|
||||
<th>Message</th>
|
||||
<th data-sort="created_at">Date</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissionsBody">
|
||||
<tr>
|
||||
<td colspan="7" class="no-data">Loading submissions...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination" id="pagination"></div>
|
||||
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let currentSortBy = "created_at";
|
||||
let currentSortOrder = "desc";
|
||||
|
||||
// Load submissions on page load
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
loadSubmissions();
|
||||
});
|
||||
|
||||
// Handle filter form submission
|
||||
document
|
||||
.getElementById("filterForm")
|
||||
.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
currentPage = 1;
|
||||
loadSubmissions();
|
||||
});
|
||||
|
||||
// Handle table header sorting
|
||||
document.querySelectorAll("th[data-sort]").forEach((header) => {
|
||||
header.addEventListener("click", function () {
|
||||
const sortBy = this.dataset.sort;
|
||||
if (currentSortBy === sortBy) {
|
||||
currentSortOrder = currentSortOrder === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
currentSortBy = sortBy;
|
||||
currentSortOrder = "asc";
|
||||
}
|
||||
currentPage = 1;
|
||||
loadSubmissions();
|
||||
});
|
||||
});
|
||||
|
||||
function clearFilters() {
|
||||
document.getElementById("email").value = "";
|
||||
document.getElementById("date_from").value = "";
|
||||
document.getElementById("date_to").value = "";
|
||||
document.getElementById("per_page").value = "50";
|
||||
currentPage = 1;
|
||||
currentSortBy = "created_at";
|
||||
currentSortOrder = "desc";
|
||||
loadSubmissions();
|
||||
}
|
||||
|
||||
function loadSubmissions() {
|
||||
const loading = document.getElementById("loading");
|
||||
const table = document.getElementById("submissionsTable");
|
||||
|
||||
loading.style.display = "block";
|
||||
table.style.opacity = "0.5";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage,
|
||||
per_page: document.getElementById("per_page").value,
|
||||
sort_by: currentSortBy,
|
||||
sort_order: currentSortOrder,
|
||||
email: document.getElementById("email").value,
|
||||
date_from: document.getElementById("date_from").value,
|
||||
date_to: document.getElementById("date_to").value,
|
||||
});
|
||||
|
||||
fetch(`/api/contact?${params}`)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
displaySubmissions(data.submissions);
|
||||
displayPagination(data.pagination);
|
||||
} else {
|
||||
showMessage(
|
||||
"Error loading submissions: " +
|
||||
(data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error loading submissions", "error");
|
||||
})
|
||||
.finally(() => {
|
||||
loading.style.display = "none";
|
||||
table.style.opacity = "1";
|
||||
});
|
||||
}
|
||||
|
||||
function displaySubmissions(submissions) {
|
||||
const tbody = document.getElementById("submissionsBody");
|
||||
|
||||
if (submissions.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="7" class="no-data">No submissions found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = submissions
|
||||
.map(
|
||||
(submission) => `
|
||||
<tr>
|
||||
<td>${submission.id}</td>
|
||||
<td>${escapeHtml(submission.name)}</td>
|
||||
<td>${escapeHtml(submission.email)}</td>
|
||||
<td>${escapeHtml(submission.company || "")}</td>
|
||||
<td class="submission-details">${escapeHtml(
|
||||
submission.message
|
||||
)}</td>
|
||||
<td>${new Date(submission.created_at).toLocaleString()}</td>
|
||||
<td><button class="delete-btn" onclick="deleteSubmission(${
|
||||
submission.id
|
||||
})">Delete</button></td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function displayPagination(pagination) {
|
||||
const paginationDiv = document.getElementById("pagination");
|
||||
|
||||
if (pagination.pages <= 1) {
|
||||
paginationDiv.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let buttons = [];
|
||||
|
||||
// Previous button
|
||||
buttons.push(
|
||||
`<button ${
|
||||
pagination.page <= 1 ? "disabled" : ""
|
||||
} onclick="changePage(${pagination.page - 1})">Previous</button>`
|
||||
);
|
||||
|
||||
// Page numbers
|
||||
const startPage = Math.max(1, pagination.page - 2);
|
||||
const endPage = Math.min(pagination.pages, pagination.page + 2);
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
buttons.push(
|
||||
`<button class="${
|
||||
i === pagination.page ? "active" : ""
|
||||
}" onclick="changePage(${i})">${i}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
// Next button
|
||||
buttons.push(
|
||||
`<button ${
|
||||
pagination.page >= pagination.pages ? "disabled" : ""
|
||||
} onclick="changePage(${pagination.page + 1})">Next</button>`
|
||||
);
|
||||
|
||||
paginationDiv.innerHTML = buttons.join("");
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
currentPage = page;
|
||||
loadSubmissions();
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function deleteSubmission(id) {
|
||||
if (!confirm("Are you sure you want to delete this submission?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/contact/${id}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (data.status === "ok") {
|
||||
showMessage("Submission deleted successfully", "success");
|
||||
loadSubmissions(); // Reload the current page
|
||||
} else {
|
||||
showMessage(
|
||||
"Error deleting submission: " +
|
||||
(data.message || "Unknown error"),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
showMessage("Error deleting submission", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageDiv = document.getElementById("message");
|
||||
messageDiv.className = `message ${type}`;
|
||||
messageDiv.textContent = text;
|
||||
messageDiv.style.display = "block";
|
||||
|
||||
// Auto-hide after 5 seconds
|
||||
setTimeout(() => {
|
||||
messageDiv.style.display = "none";
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
81
templates/embed_contact.html
Normal file
81
templates/embed_contact.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Contact Form</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="contact-form">
|
||||
<h2>Contact Us</h2>
|
||||
<form id="contactForm" method="post" action="/api/contact">
|
||||
<div class="form-group">
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" id="name" name="name" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email:</label>
|
||||
<input type="email" id="email" name="email" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="message">Message:</label>
|
||||
<textarea id="message" name="message" required></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="consent"
|
||||
name="consent"
|
||||
required
|
||||
style="display: inline-block; width: auto"
|
||||
/>
|
||||
<label for="consent" style="display: inline-block">
|
||||
<span style="color: red">*</span> I agree to the processing of my
|
||||
submitted data.</label
|
||||
>
|
||||
</div>
|
||||
<button id="contactSubmit" class="btn" type="submit" disabled="true">
|
||||
Send Message
|
||||
</button>
|
||||
</form>
|
||||
<div id="responseMessage"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document
|
||||
.getElementById("consent")
|
||||
.addEventListener("change", function (e) {
|
||||
const submitButton = document.getElementById("contactSubmit");
|
||||
submitButton.disabled = !e.target.checked;
|
||||
});
|
||||
document
|
||||
.getElementById("contactForm")
|
||||
.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
fetch("/api/contact", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
const messageDiv = document.getElementById("responseMessage");
|
||||
if (data.status === "ok") {
|
||||
messageDiv.innerHTML =
|
||||
'<div class="message success">Thank you for your message! We will get back to you soon.</div>';
|
||||
this.reset();
|
||||
} else {
|
||||
messageDiv.innerHTML =
|
||||
'<div class="message error">There was an error sending your message. Please try again.</div>';
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
document.getElementById("responseMessage").innerHTML =
|
||||
'<div class="message error">There was an error sending your message. Please try again.</div>';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
55
templates/embed_newsletter.html
Normal file
55
templates/embed_newsletter.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Newsletter Subscription</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="form-container">
|
||||
<h2>Subscribe to Our Newsletter</h2>
|
||||
<form id="newsletterForm" method="post" action="/api/newsletter">
|
||||
<div class="form-group">
|
||||
<label for="email">Email:</label>
|
||||
<input type="email" id="email" name="email" required />
|
||||
</div>
|
||||
<button type="submit">Subscribe</button>
|
||||
</form>
|
||||
<div id="responseMessage"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document
|
||||
.getElementById("newsletterForm")
|
||||
.addEventListener("submit", function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(this);
|
||||
fetch("/api/newsletter", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
const messageDiv = document.getElementById("responseMessage");
|
||||
if (data.status === "ok") {
|
||||
messageDiv.innerHTML =
|
||||
'<div class="message success">Thank you for subscribing! Please check your email for confirmation.</div>';
|
||||
this.reset();
|
||||
} else {
|
||||
messageDiv.innerHTML =
|
||||
'<div class="message error">' +
|
||||
(data.message ||
|
||||
"There was an error subscribing. Please try again.") +
|
||||
"</div>";
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error);
|
||||
document.getElementById("responseMessage").innerHTML =
|
||||
'<div class="message error">There was an error subscribing. Please try again.</div>';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,27 +4,14 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Login</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 40px;
|
||||
}
|
||||
form {
|
||||
.login-form {
|
||||
max-width: 300px;
|
||||
margin: auto;
|
||||
margin: 40px auto;
|
||||
}
|
||||
input {
|
||||
display: block;
|
||||
.login-form input {
|
||||
margin: 10px 0;
|
||||
padding: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
button {
|
||||
padding: 10px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.flash {
|
||||
color: red;
|
||||
@@ -34,13 +21,22 @@
|
||||
<body>
|
||||
<h1>Admin Login</h1>
|
||||
{% with messages = get_flashed_messages() %} {% if messages %}
|
||||
<div class="flash">
|
||||
<div class="message error">
|
||||
{% for message in messages %} {{ message }} {% endfor %}
|
||||
</div>
|
||||
{% endif %} {% endwith %}
|
||||
<form method="post">
|
||||
<form method="post" class="login-form">
|
||||
<div class="form-group">
|
||||
<input type="text" name="username" placeholder="Username" required />
|
||||
<input type="password" name="password" placeholder="Password" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</body>
|
||||
|
||||
@@ -4,69 +4,18 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Newsletter Management</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
margin: 20px auto;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
text-align: center;
|
||||
}
|
||||
.message {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.message.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
.form-section {
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.form-section h2 {
|
||||
margin-top: 0;
|
||||
color: #555;
|
||||
}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
input[type="email"] {
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
button {
|
||||
padding: 10px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.unsubscribe-btn {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
@@ -93,12 +42,14 @@
|
||||
<h2>Subscribe to Newsletter</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="subscribe" />
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit">Subscribe</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -107,12 +58,14 @@
|
||||
<h2>Unsubscribe from Newsletter</h2>
|
||||
<form method="post">
|
||||
<input type="hidden" name="action" value="unsubscribe" />
|
||||
<div class="form-group">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="unsubscribe-btn">Unsubscribe</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
38
templates/unsubscribe_confirmation.html
Normal file
38
templates/unsubscribe_confirmation.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Unsubscription Confirmed</title>
|
||||
<link rel="stylesheet" href="/static/css/styles.css" />
|
||||
<style>
|
||||
.confirmation-container {
|
||||
text-align: center;
|
||||
}
|
||||
.icon {
|
||||
font-size: 48px;
|
||||
color: #28a745;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.back-link {
|
||||
margin-top: 30px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="confirmation-container">
|
||||
<div class="icon">✓</div>
|
||||
<h1>Unsubscription Confirmed</h1>
|
||||
<p>
|
||||
You have been successfully unsubscribed from our newsletter. We're sorry
|
||||
to see you go, but you can always subscribe again if you change your
|
||||
mind.
|
||||
</p>
|
||||
<p>
|
||||
If you unsubscribed by mistake or have any questions, please feel free
|
||||
to contact us.
|
||||
</p>
|
||||
<a href="/newsletter/manage" class="back-link">Return to Newsletter</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
15
test_embed.html
Normal file
15
test_embed.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test Embed</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Test Embedding Contact Form</h1>
|
||||
<iframe src="http://localhost:5001/embed/contact" width="600" height="400" frameborder="0" allowfullscreen></iframe>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
15
test_embed_newsletter.html
Normal file
15
test_embed_newsletter.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Test Embed Newsletter</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<h1>Test Embedding Newsletter Subscription Form</h1>
|
||||
<iframe src="http://localhost:5001/embed/newsletter" width="600" height="300" frameborder="0" allowfullscreen></iframe>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -28,3 +28,35 @@ def setup_tmp_db(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr("server.settings.ADMIN_PASSWORD", "admin")
|
||||
init_db()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="function")
|
||||
def stub_smtp(monkeypatch):
|
||||
"""Replace smtplib SMTP clients with fast stubs to avoid real network calls."""
|
||||
|
||||
class _DummySMTP:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def starttls(self):
|
||||
return None
|
||||
|
||||
def login(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def send_message(self, *args, **kwargs):
|
||||
return {}
|
||||
|
||||
def sendmail(self, *args, **kwargs):
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr("smtplib.SMTP", _DummySMTP)
|
||||
monkeypatch.setattr("smtplib.SMTP_SSL", _DummySMTP)
|
||||
yield
|
||||
|
||||
101
tests/test_admin_email_settings_api.py
Normal file
101
tests/test_admin_email_settings_api.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
server_app_module = importlib.import_module("server.app")
|
||||
app = server_app_module.app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with app.test_client() as client:
|
||||
yield client
|
||||
|
||||
|
||||
def login(client):
|
||||
return client.post("/auth/login", data={"username": "admin", "password": "admin"})
|
||||
|
||||
|
||||
def test_email_settings_get_requires_auth(client):
|
||||
resp = client.get("/admin/api/email-settings")
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers["Location"] == "/auth/login"
|
||||
|
||||
|
||||
def test_email_settings_get_with_auth_returns_defaults(client):
|
||||
login(client)
|
||||
|
||||
resp = client.get("/admin/api/email-settings")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
|
||||
assert data["status"] == "ok"
|
||||
assert isinstance(data["settings"], dict)
|
||||
assert "smtp_host" in data["settings"]
|
||||
assert "schema" in data
|
||||
assert "smtp_host" in data["schema"]
|
||||
|
||||
|
||||
def test_email_settings_update_validation_error(client):
|
||||
login(client)
|
||||
|
||||
payload = {
|
||||
"smtp_host": "",
|
||||
"smtp_port": 70000,
|
||||
"smtp_sender": "not-an-email",
|
||||
"smtp_recipients": "owner@example.com, invalid",
|
||||
}
|
||||
|
||||
resp = client.put("/admin/api/email-settings", json=payload)
|
||||
assert resp.status_code == 400
|
||||
|
||||
data = resp.get_json()
|
||||
assert data["status"] == "error"
|
||||
assert "smtp_host" in data["errors"]
|
||||
assert "smtp_port" in data["errors"]
|
||||
assert "smtp_sender" in data["errors"]
|
||||
assert "smtp_recipients" in data["errors"]
|
||||
|
||||
|
||||
def test_email_settings_update_persists_and_returns_values(client):
|
||||
login(client)
|
||||
|
||||
payload = {
|
||||
"smtp_host": "smtp.acme.test",
|
||||
"smtp_port": 2525,
|
||||
"smtp_username": "mailer",
|
||||
"smtp_password": "secret",
|
||||
"smtp_sender": "robot@acme.test",
|
||||
"smtp_recipients": "alerts@acme.test, ops@acme.test",
|
||||
"smtp_use_tls": True,
|
||||
"notify_contact_form": True,
|
||||
"notify_newsletter_signups": False,
|
||||
}
|
||||
|
||||
resp = client.put("/admin/api/email-settings", json=payload)
|
||||
assert resp.status_code == 200
|
||||
|
||||
data = resp.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["settings"]["smtp_port"] == 2525
|
||||
assert data["settings"]["smtp_use_tls"] is True
|
||||
assert data["settings"]["smtp_recipients"] == [
|
||||
"alerts@acme.test",
|
||||
"ops@acme.test",
|
||||
]
|
||||
assert data["settings"]["notify_contact_form"] is True
|
||||
assert data["settings"]["notify_newsletter_signups"] is False
|
||||
|
||||
# Fetch again to verify persistence
|
||||
resp_get = client.get("/admin/api/email-settings")
|
||||
assert resp_get.status_code == 200
|
||||
stored = resp_get.get_json()["settings"]
|
||||
assert stored["smtp_host"] == "smtp.acme.test"
|
||||
assert stored["smtp_port"] == 2525
|
||||
assert stored["smtp_sender"] == "robot@acme.test"
|
||||
assert stored["smtp_recipients"] == [
|
||||
"alerts@acme.test",
|
||||
"ops@acme.test",
|
||||
]
|
||||
@@ -8,16 +8,16 @@ import pytest
|
||||
from server.services import contact as contact_service # noqa: E402 pylint: disable=wrong-import-position
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_settings(monkeypatch):
|
||||
original = contact_service.settings.SMTP_SETTINGS.copy()
|
||||
patched = original.copy()
|
||||
monkeypatch.setattr(contact_service.settings, "SMTP_SETTINGS", patched)
|
||||
return patched
|
||||
|
||||
|
||||
def test_send_notification_returns_false_when_unconfigured(monkeypatch, patched_settings):
|
||||
patched_settings.update({"host": None, "recipients": []})
|
||||
def test_send_notification_returns_false_when_unconfigured(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
contact_service,
|
||||
"load_effective_smtp_settings",
|
||||
lambda: {
|
||||
"notify_contact_form": True,
|
||||
"host": None,
|
||||
"recipients": [],
|
||||
},
|
||||
)
|
||||
|
||||
# Ensure we do not accidentally open a socket if called
|
||||
monkeypatch.setattr(contact_service.smtplib, "SMTP", pytest.fail)
|
||||
@@ -33,9 +33,8 @@ def test_send_notification_returns_false_when_unconfigured(monkeypatch, patched_
|
||||
assert contact_service.send_notification(submission) is False
|
||||
|
||||
|
||||
def test_send_notification_sends_email(monkeypatch, patched_settings):
|
||||
patched_settings.update(
|
||||
{
|
||||
def test_send_notification_sends_email(monkeypatch):
|
||||
smtp_config = {
|
||||
"host": "smtp.example.com",
|
||||
"port": 2525,
|
||||
"sender": "sender@example.com",
|
||||
@@ -43,7 +42,13 @@ def test_send_notification_sends_email(monkeypatch, patched_settings):
|
||||
"password": "secret",
|
||||
"use_tls": True,
|
||||
"recipients": ["owner@example.com"],
|
||||
"notify_contact_form": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(
|
||||
contact_service,
|
||||
"load_effective_smtp_settings",
|
||||
lambda: smtp_config,
|
||||
)
|
||||
|
||||
smtp_calls: dict[str, Any] = {}
|
||||
@@ -80,13 +85,13 @@ def test_send_notification_sends_email(monkeypatch, patched_settings):
|
||||
assert contact_service.send_notification(submission) is True
|
||||
|
||||
assert smtp_calls["init"] == (
|
||||
patched_settings["host"],
|
||||
patched_settings["port"],
|
||||
smtp_config["host"],
|
||||
smtp_config["port"],
|
||||
15,
|
||||
)
|
||||
assert smtp_calls["starttls"] is True
|
||||
assert smtp_calls["login"] == (
|
||||
patched_settings["username"], patched_settings["password"])
|
||||
smtp_config["username"], smtp_config["password"])
|
||||
|
||||
message = cast(EmailMessage, smtp_calls["message"])
|
||||
assert message["Subject"] == "Neue Kontaktanfrage von Alice"
|
||||
|
||||
167
tests/test_email_settings_service.py
Normal file
167
tests/test_email_settings_service.py
Normal file
@@ -0,0 +1,167 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from server.services import email_settings
|
||||
|
||||
|
||||
def _defaults() -> Dict[str, Any]:
|
||||
return {
|
||||
field: meta["default"]
|
||||
for field, meta in email_settings.EMAIL_SETTINGS_DEFINITIONS.items()
|
||||
}
|
||||
|
||||
|
||||
def test_load_email_settings_returns_defaults_when_storage_empty(monkeypatch):
|
||||
monkeypatch.setattr(email_settings, "get_app_settings", lambda: {})
|
||||
|
||||
settings = email_settings.load_email_settings()
|
||||
|
||||
assert settings == _defaults()
|
||||
|
||||
|
||||
def test_load_email_settings_deserializes_persisted_values(monkeypatch):
|
||||
stored = {
|
||||
"email_smtp_host": "smtp.acme.test",
|
||||
"email_smtp_port": "2525",
|
||||
"email_smtp_username": "mailer",
|
||||
"email_smtp_password": "sup3rs3cret",
|
||||
"email_smtp_sender": "robot@acme.test",
|
||||
"email_smtp_use_tls": "false",
|
||||
"email_smtp_recipients": "alerts@acme.test, ops@acme.test",
|
||||
"email_notify_contact_form": "true",
|
||||
"email_notify_newsletter_signups": "false",
|
||||
}
|
||||
monkeypatch.setattr(email_settings, "get_app_settings", lambda: stored)
|
||||
|
||||
settings = email_settings.load_email_settings()
|
||||
|
||||
assert settings["smtp_host"] == "smtp.acme.test"
|
||||
assert settings["smtp_port"] == 2525
|
||||
assert settings["smtp_username"] == "mailer"
|
||||
assert settings["smtp_password"] == "sup3rs3cret"
|
||||
assert settings["smtp_sender"] == "robot@acme.test"
|
||||
assert settings["smtp_use_tls"] is False
|
||||
assert settings["smtp_recipients"] == [
|
||||
"alerts@acme.test",
|
||||
"ops@acme.test",
|
||||
]
|
||||
assert settings["notify_contact_form"] is True
|
||||
assert settings["notify_newsletter_signups"] is False
|
||||
|
||||
|
||||
def test_validate_email_settings_detects_invalid_values():
|
||||
payload = {
|
||||
"smtp_host": "",
|
||||
"smtp_port": "not-a-number",
|
||||
"smtp_sender": "invalid-address",
|
||||
"smtp_recipients": "good@example.com, bad-address",
|
||||
"smtp_use_tls": "maybe",
|
||||
}
|
||||
|
||||
errors = email_settings.validate_email_settings(payload)
|
||||
|
||||
assert "smtp_host" in errors
|
||||
assert "smtp_port" in errors
|
||||
assert "smtp_sender" in errors
|
||||
assert "smtp_recipients" in errors
|
||||
assert "smtp_use_tls" in errors
|
||||
|
||||
|
||||
def test_persist_email_settings_serializes_and_updates(monkeypatch):
|
||||
calls: List[tuple[str, str]] = []
|
||||
|
||||
def fake_update(key: str, value: str) -> bool:
|
||||
calls.append((key, value))
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(email_settings, "update_app_setting", fake_update)
|
||||
|
||||
payload = {
|
||||
"smtp_host": "smtp.acme.test",
|
||||
"smtp_port": 2525,
|
||||
"smtp_username": "mailer",
|
||||
"smtp_password": "password123",
|
||||
"smtp_sender": "robot@acme.test",
|
||||
"smtp_use_tls": True,
|
||||
"smtp_recipients": "alerts@acme.test, ops@acme.test",
|
||||
"notify_contact_form": False,
|
||||
"notify_newsletter_signups": True,
|
||||
}
|
||||
|
||||
normalized = email_settings.persist_email_settings(payload)
|
||||
|
||||
assert normalized["smtp_port"] == 2525
|
||||
assert normalized["smtp_use_tls"] is True
|
||||
assert normalized["smtp_recipients"] == [
|
||||
"alerts@acme.test",
|
||||
"ops@acme.test",
|
||||
]
|
||||
assert normalized["notify_contact_form"] is False
|
||||
assert normalized["notify_newsletter_signups"] is True
|
||||
|
||||
expected_keys = {
|
||||
"email_smtp_host",
|
||||
"email_smtp_port",
|
||||
"email_smtp_username",
|
||||
"email_smtp_password",
|
||||
"email_smtp_sender",
|
||||
"email_smtp_use_tls",
|
||||
"email_smtp_recipients",
|
||||
"email_notify_contact_form",
|
||||
"email_notify_newsletter_signups",
|
||||
}
|
||||
assert {key for key, _ in calls} == expected_keys
|
||||
assert ("email_smtp_port", "2525") in calls
|
||||
assert ("email_smtp_use_tls", "true") in calls
|
||||
assert (
|
||||
"email_smtp_recipients",
|
||||
"alerts@acme.test, ops@acme.test",
|
||||
) in calls
|
||||
|
||||
|
||||
def test_load_effective_smtp_settings_merges_defaults(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
email_settings,
|
||||
"SMTP_SETTINGS",
|
||||
{
|
||||
"host": "fallback.mail",
|
||||
"port": 465,
|
||||
"username": "fallback",
|
||||
"password": "fallback-pass",
|
||||
"sender": "default@fallback.mail",
|
||||
"use_tls": True,
|
||||
"recipients": ["owner@fallback.mail"],
|
||||
},
|
||||
raising=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
email_settings,
|
||||
"load_email_settings",
|
||||
lambda: {
|
||||
"smtp_host": "",
|
||||
"smtp_port": "",
|
||||
"smtp_username": "",
|
||||
"smtp_password": "",
|
||||
"smtp_sender": "",
|
||||
"smtp_use_tls": False,
|
||||
"smtp_recipients": "",
|
||||
"notify_contact_form": True,
|
||||
"notify_newsletter_signups": False,
|
||||
},
|
||||
)
|
||||
|
||||
effective = email_settings.load_effective_smtp_settings()
|
||||
|
||||
assert effective["host"] == "fallback.mail"
|
||||
assert effective["port"] == 465
|
||||
assert effective["username"] == "fallback"
|
||||
assert effective["password"] == "fallback-pass"
|
||||
assert effective["sender"] == "default@fallback.mail"
|
||||
assert effective["use_tls"] is False
|
||||
assert effective["recipients"] == []
|
||||
assert effective["notify_contact_form"] is True
|
||||
assert effective["notify_newsletter_signups"] is False
|
||||
76
tests/test_email_templates_api.py
Normal file
76
tests/test_email_templates_api.py
Normal file
@@ -0,0 +1,76 @@
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
server_app_module = importlib.import_module("server.app")
|
||||
app = server_app_module.app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
with app.test_client() as client:
|
||||
yield client
|
||||
|
||||
|
||||
def login(client):
|
||||
return client.post('/auth/login', data={'username': 'admin', 'password': 'admin'})
|
||||
|
||||
|
||||
def test_email_template_list_requires_auth(client):
|
||||
resp = client.get('/admin/api/email-templates')
|
||||
assert resp.status_code == 302
|
||||
assert resp.headers['Location'] == '/auth/login'
|
||||
|
||||
|
||||
def test_list_email_templates_returns_metadata(client):
|
||||
login(client)
|
||||
resp = client.get('/admin/api/email-templates')
|
||||
assert resp.status_code == 200
|
||||
|
||||
payload = resp.get_json()
|
||||
assert payload['status'] == 'ok'
|
||||
assert isinstance(payload['templates'], list)
|
||||
assert payload['templates'][0]['id'] == 'newsletter_confirmation'
|
||||
|
||||
|
||||
def test_get_email_template_returns_content(client):
|
||||
login(client)
|
||||
resp = client.get('/admin/api/email-templates/newsletter_confirmation')
|
||||
assert resp.status_code == 200
|
||||
|
||||
payload = resp.get_json()
|
||||
assert payload['status'] == 'ok'
|
||||
template = payload['template']
|
||||
assert template['id'] == 'newsletter_confirmation'
|
||||
assert 'content' in template
|
||||
|
||||
|
||||
def test_update_email_template_persists_content(client):
|
||||
login(client)
|
||||
new_content = '<p>Updated template {{email}}</p>'
|
||||
|
||||
resp = client.put(
|
||||
'/admin/api/email-templates/newsletter_confirmation',
|
||||
json={'content': new_content},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
payload = resp.get_json()
|
||||
assert payload['status'] == 'ok'
|
||||
assert payload['template']['content'] == new_content
|
||||
|
||||
# Fetch again to ensure persistence
|
||||
resp_get = client.get('/admin/api/email-templates/newsletter_confirmation')
|
||||
assert resp_get.status_code == 200
|
||||
template = resp_get.get_json()['template']
|
||||
assert template['content'] == new_content
|
||||
|
||||
|
||||
def test_update_email_template_requires_content(client):
|
||||
login(client)
|
||||
resp = client.put(
|
||||
'/admin/api/email-templates/newsletter_confirmation',
|
||||
json={'content': ' '},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
payload = resp.get_json()
|
||||
assert payload['status'] == 'error'
|
||||
44
tests/test_email_templates_service.py
Normal file
44
tests/test_email_templates_service.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from server.services import email_templates
|
||||
|
||||
|
||||
def test_list_templates_returns_metadata():
|
||||
templates = email_templates.list_templates()
|
||||
assert isinstance(templates, list)
|
||||
assert templates[0]["id"] == "newsletter_confirmation"
|
||||
assert "name" in templates[0]
|
||||
|
||||
|
||||
def test_load_template_uses_default_when_not_stored(monkeypatch):
|
||||
monkeypatch.setattr(email_templates, "get_app_settings", lambda: {})
|
||||
template = email_templates.load_template("newsletter_confirmation")
|
||||
assert template["content"] == email_templates.EMAIL_TEMPLATE_DEFINITIONS[
|
||||
"newsletter_confirmation"
|
||||
].default_content
|
||||
|
||||
|
||||
def test_persist_template_updates_storage(monkeypatch):
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def fake_update(key: str, value: str) -> None:
|
||||
captured["key"] = key
|
||||
captured["value"] = value
|
||||
|
||||
# Return content via load call after persist
|
||||
monkeypatch.setattr(email_templates, "update_app_setting", fake_update)
|
||||
monkeypatch.setattr(
|
||||
email_templates,
|
||||
"get_app_settings",
|
||||
lambda: {"newsletter_confirmation_template": "stored"},
|
||||
)
|
||||
|
||||
updated = email_templates.persist_template(
|
||||
"newsletter_confirmation", " stored ")
|
||||
assert captured["key"] == "newsletter_confirmation_template"
|
||||
assert captured["value"] == "stored"
|
||||
assert updated["content"] == "stored"
|
||||
@@ -76,13 +76,13 @@ def test_newsletter_update_email_not_found(client):
|
||||
|
||||
|
||||
def test_newsletter_manage_page_get(client):
|
||||
resp = client.get("/api/newsletter/manage")
|
||||
resp = client.get("/newsletter/manage")
|
||||
assert resp.status_code == 200
|
||||
assert b"Newsletter Subscription Management" in resp.data
|
||||
|
||||
|
||||
def test_newsletter_manage_subscribe(client):
|
||||
resp = client.post("/api/newsletter/manage",
|
||||
resp = client.post("/newsletter/manage",
|
||||
data={"email": "manage@example.com", "action": "subscribe"})
|
||||
assert resp.status_code == 200
|
||||
assert b"Successfully subscribed" in resp.data
|
||||
@@ -90,29 +90,29 @@ def test_newsletter_manage_subscribe(client):
|
||||
|
||||
def test_newsletter_manage_unsubscribe(client):
|
||||
# Subscribe first
|
||||
client.post("/api/newsletter/manage",
|
||||
client.post("/newsletter/manage",
|
||||
data={"email": "manage@example.com", "action": "subscribe"})
|
||||
# Unsubscribe
|
||||
resp = client.post("/api/newsletter/manage",
|
||||
resp = client.post("/newsletter/manage",
|
||||
data={"email": "manage@example.com", "action": "unsubscribe"})
|
||||
assert resp.status_code == 200
|
||||
assert b"Successfully unsubscribed" in resp.data
|
||||
assert b"Unsubscription Confirmed" in resp.data
|
||||
|
||||
|
||||
def test_newsletter_manage_update(client):
|
||||
# Subscribe first
|
||||
client.post("/api/newsletter/manage",
|
||||
client.post("/newsletter/manage",
|
||||
data={"email": "old@example.com", "action": "subscribe"})
|
||||
# Update
|
||||
resp = client.post("/api/newsletter/manage", data={
|
||||
"old_email": "old@example.com", "new_email": "updated@example.com", "action": "update"})
|
||||
resp = client.post("/newsletter/manage", data={
|
||||
"old_email": "old@example.com", "email": "updated@example.com", "action": "update"})
|
||||
assert resp.status_code == 200
|
||||
# Check that some success message is displayed
|
||||
assert b"success" in resp.data.lower() or b"updated" in resp.data.lower()
|
||||
assert b"updated successfully" in resp.data.lower()
|
||||
|
||||
|
||||
def test_newsletter_manage_invalid_email(client):
|
||||
resp = client.post("/api/newsletter/manage",
|
||||
resp = client.post("/newsletter/manage",
|
||||
data={"email": "invalid-email", "action": "subscribe"})
|
||||
assert resp.status_code == 200
|
||||
assert b"Please enter a valid email address" in resp.data
|
||||
|
||||
Reference in New Issue
Block a user