commit 0ebb8e7f4b1a4aa026682d8764fb33068f671f2b Author: jpmvaz Date: Sun Sep 13 20:03:24 2026 +0100 v_9.3 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..51b61a3 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# NOTE: Docker Compose treats '$' in this file as variable substitution. +# If any value (e.g. a password) contains a literal '$', write it as '$$': +# MAIL_PASSWORD=xK9$$vh4pQ2 -> the container receives xK9$vh4pQ2 +# Otherwise you'll see a warning like: The "..." variable is not set. + +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Purelymail account (only login and password are needed) --- +# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS. +# If Two Factor Authentication is enabled on the account, use an App Password. +# Leave MAIL_USER empty to print outgoing mail to the console (dev mode). +MAIL_USER=you@yourdomain.com +MAIL_PASSWORD=secret diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v1.0/approvalflow.zip b/OLD_VERSIONS/v1.0/approvalflow.zip new file mode 100644 index 0000000..c6cf082 Binary files /dev/null and b/OLD_VERSIONS/v1.0/approvalflow.zip differ diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..ed07a41 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/.env.example @@ -0,0 +1,21 @@ +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Inbound email (IMAP) — leave IMAP_HOST empty to disable email intake --- +IMAP_HOST=imap.example.com +IMAP_USER=inbox@example.com +IMAP_PASSWORD=secret +IMAP_FOLDER=INBOX +IMAP_POLL_SECONDS=30 + +# --- Outbound email (SMTP) — leave SMTP_HOST empty to print mail to console (dev) --- +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURITY=starttls # starttls | ssl | none +SMTP_USER=inbox@example.com +SMTP_PASSWORD=secret +SMTP_FROM=approvals@example.com diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..44ab993 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/README.md @@ -0,0 +1,79 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests arrive by email (or through a login-protected web form), get redirected to an approver, and once approved or denied, **everyone involved up to that point** is notified by email. Every request keeps a full audit log of everything that happened to it. + +## What it does + +- **Email intake.** An IMAP poller watches a mailbox. When a message arrives from a workflow's allowed domain, a request is created and redirected to that workflow's approver with one-click **Approve** / **Deny** links. Mail from any other domain is rejected (and logged). +- **Decisions notify everyone.** On approve or deny, the requester, anyone CC'd on the original email, and the approver all receive the outcome email. +- **Full log.** Every request records: received, matched workflow, approval requested, decision, notifications sent, and any mail errors — each with a timestamp. +- **Multiple named workflows**, each personalized: its own allowed sender domain, approver, and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, IMAP_*, SMTP_* +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `SMTP_HOST` empty prints outgoing mail to the console and leaving `IMAP_HOST` empty disables email intake — handy for trying the app before wiring up a mailbox. + +## How email intake targets a workflow + +1. **Plus-addressing:** mail sent to `inbox+@your-domain` targets that workflow. +2. **Subject tag:** a subject containing `[]`. +3. **Fallback:** the first active workflow whose allowed domain matches the sender. + +Whichever way a workflow is matched, the sender's domain must equal the workflow's allowed domain or the request is marked **rejected** and logged. + +## The request lifecycle + +``` +email / web form + │ received + logged + ▼ + approval request → approver (approve/deny links) + │ logged + ▼ + approver clicks a link (or admin decides in the UI) + │ decision logged + ▼ + APPROVED or DENIED notice → everyone involved so far + (requester + CCs + approver) logged +``` + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{decided_by}`, `{approve_url}`, `{deny_url}`. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit-log helpers +pipeline.py approval request + decision/notification logic +poller.py IMAP inbox watcher (background thread) +mailer.py SMTP sending + template rendering +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..110a2dd --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/app.py @@ -0,0 +1,284 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash + +import db +import pipeline +import poller +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) + + +# ---------------------------------------------------------------- helpers + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(request.args.get("next") or url_for("index")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + if not subject or not body: + flash("Subject and message are both required.", "error") + else: + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + requester = user["email"] or user["username"] + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + g.db.commit() + pipeline.send_approval_request(rid) + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.source='web' AND (r.requester=? OR r.requester=?) ORDER BY r.id DESC LIMIT 15", + (session["username"], + (g.db.execute("SELECT email FROM users WHERE id=?", (session["user_id"],)).fetchone()["email"] or "-"), + )).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests) + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approver_email FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or req["approver_email"] + status, req2, message = pipeline.decide(token, action, decided_by) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)") + return redirect(url_for("admin_request", rid=rid)) + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + return render_template("request_detail.html", req=req, logs=logs, participants=participants) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + allowed_domain = f.get("allowed_domain", "").strip().lstrip("@").lower() + approver_email = f.get("approver_email", "").strip() + if not (name and allowed_domain and approver_email): + flash("Name, allowed domain and approver email are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, allowed_domain, approver_email, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), allowed_domain, + approver_email, 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created.", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, allowed_domain=?," + " approver_email=?, active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), allowed_domain, + approver_email, 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() +poller.start() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..86604ae --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/db.py @@ -0,0 +1,157 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + allowed_domain TEXT NOT NULL, -- only senders @this domain are accepted + approver_email TEXT NOT NULL, -- who receives the approval request + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL, -- 'email' or 'web' + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied / rejected + token TEXT UNIQUE NOT NULL, -- secret for approve/deny links + decided_by TEXT DEFAULT '', + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed: {subject}", + "approval_body": ( + "A new request needs your decision.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + return con + + +def init_db(): + con = get_db() + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..e0da6b3 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/mailer.py @@ -0,0 +1,54 @@ +"""Outbound email (SMTP) and template rendering.""" +import os +import smtplib +from email.message import EmailMessage + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def send_mail(to_addrs, subject, body): + """Send a plain-text email. Returns (ok, detail).""" + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + host = os.environ.get("SMTP_HOST", "") + port = int(os.environ.get("SMTP_PORT", "587")) + user = os.environ.get("SMTP_USER", "") + password = os.environ.get("SMTP_PASSWORD", "") + sender = os.environ.get("SMTP_FROM", user or "approvalflow@localhost") + security = os.environ.get("SMTP_SECURITY", "starttls").lower() # starttls / ssl / none + + msg = EmailMessage() + msg["From"] = sender + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + msg.set_content(body) + + if not host: + # Dev mode: print instead of sending so the app is testable without SMTP. + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}") + return True, "dev mode (SMTP_HOST not set) — printed to console" + + try: + if security == "ssl": + server = smtplib.SMTP_SSL(host, port, timeout=30) + else: + server = smtplib.SMTP(host, port, timeout=30) + if security == "starttls": + server.starttls() + if user: + server.login(user, password) + server.send_message(msg) + server.quit() + return True, f"sent to {', '.join(to_addrs)}" + except Exception as e: + return False, f"SMTP error: {e}" diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..3ac20b4 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,76 @@ +"""Request pipeline shared by the web form and the email poller.""" +import os + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def _vars(wf, req, **extra): + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def send_approval_request(request_id): + """Redirect the request to the approver, asking for approval.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + v = _vars(wf, req) + ok, detail = send_mail(wf["approver_email"], + render(wf["approval_subject"], **v), + render(wf["approval_body"], **v)) + db.add_participants(con, request_id, [wf["approver_email"]]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Approval request to {wf['approver_email']} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def decide(token, action, decided_by): + """Approve or deny a request; notify everyone involved so far. + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + new_status = "approved" if action == "approve" else "denied" + con.execute("UPDATE requests SET status=?, decided_by=?, decided_at=? WHERE id=?", + (new_status, decided_by, db.now(), req["id"])) + db.log(con, req["id"], new_status, f"Decision by {decided_by}") + + participants = db.add_participants(con, req["id"], [decided_by]) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper()) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v)) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, f"Request #{req['id']} {new_status}. Everyone involved has been notified." + finally: + con.close() diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/poller.py b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/poller.py new file mode 100644 index 0000000..98df3cf --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/poller.py @@ -0,0 +1,146 @@ +"""IMAP inbox poller. + +Watches a mailbox for new messages and turns them into approval requests. + +Workflow matching, in order: + 1. Plus-addressing: mail sent to inbox+@domain targets that workflow. + 2. Subject tag: a subject containing [] targets that workflow. + 3. Fallback: the first active workflow whose allowed_domain matches the sender. + +The sender's domain must match the matched workflow's allowed_domain, +otherwise the message is logged and rejected. +""" +import email +import email.utils +import imaplib +import os +import re +import threading +import time +import traceback + +import db +import pipeline + + +def _addr_list(msg, header): + raw = msg.get_all(header, []) + return [a.lower() for _, a in email.utils.getaddresses(raw) if a] + + +def _body_text(msg): + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain" and not part.get("Content-Disposition"): + try: + return part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8", "replace") + except Exception: + continue + return "(no readable text body)" + payload = msg.get_payload(decode=True) + if payload is None: + return str(msg.get_payload()) + return payload.decode(msg.get_content_charset() or "utf-8", "replace") + + +def _match_workflow(con, sender, recipients, subject): + workflows = con.execute("SELECT * FROM workflows WHERE active=1").fetchall() + by_slug = {w["slug"]: w for w in workflows} + + # 1. plus-addressing: inbox+slug@... + for addr in recipients: + m = re.match(r"^[^+@]+\+([a-z0-9\-]+)@", addr) + if m and m.group(1) in by_slug: + return by_slug[m.group(1)], "plus-address" + + # 2. subject tag [slug] + for slug, w in by_slug.items(): + if f"[{slug}]" in subject.lower(): + return w, "subject tag" + + # 3. sender-domain fallback + domain = sender.split("@")[-1].lower() if "@" in sender else "" + for w in workflows: + if w["allowed_domain"].lower().lstrip("@") == domain: + return w, "sender domain" + return None, None + + +def process_message(raw_bytes): + msg = email.message_from_bytes(raw_bytes) + sender = (email.utils.parseaddr(msg.get("From", ""))[1] or "").lower() + recipients = _addr_list(msg, "To") + _addr_list(msg, "Cc") + subject = msg.get("Subject", "(no subject)") + body = _body_text(msg).strip() + + con = db.get_db() + try: + wf, how = _match_workflow(con, sender, recipients, subject) + if wf is None: + print(f"[poller] No workflow matched mail from {sender} — ignored.") + return + + sender_domain = sender.split("@")[-1] if "@" in sender else "" + allowed = wf["allowed_domain"].lower().lstrip("@") + if sender_domain != allowed: + rid, _ = db.create_request(con, wf["id"], "email", sender, subject, body) + con.execute("UPDATE requests SET status='rejected' WHERE id=?", (rid,)) + db.log(con, rid, "rejected", + f"Sender domain '{sender_domain}' is not the allowed domain '@{allowed}' for workflow '{wf['name']}'") + con.commit() + print(f"[poller] Rejected mail from {sender} for workflow {wf['name']} (wrong domain).") + return + + # CC'd people are "involved up to this point" too — everyone on To/Cc + # except the sender and the intake mailbox itself (incl. its +tag variants) + inbox_local = (os.environ.get("IMAP_USER") or "").split("@")[0].lower() + + def is_intake(addr): + base = addr.split("@")[0].split("+")[0] + return (inbox_local and base == inbox_local) or \ + addr.split("@")[0].endswith("+" + wf["slug"]) + + ccs = [a for a in recipients if a != sender and not is_intake(a)] + rid, token = db.create_request(con, wf["id"], "email", sender, subject, body, extra_participants=ccs) + db.log(con, rid, "matched", f"Matched workflow '{wf['name']}' via {how}") + con.commit() + + pipeline.send_approval_request(rid) + print(f"[poller] Request #{rid} created for workflow '{wf['name']}' from {sender}.") + except Exception: + traceback.print_exc() + finally: + con.close() + + +def poll_loop(): + host = os.environ.get("IMAP_HOST", "") + user = os.environ.get("IMAP_USER", "") + password = os.environ.get("IMAP_PASSWORD", "") + folder = os.environ.get("IMAP_FOLDER", "INBOX") + interval = int(os.environ.get("IMAP_POLL_SECONDS", "30")) + + if not host: + print("[poller] IMAP_HOST not set — email intake disabled (web form still works).") + return + + print(f"[poller] Watching {user} on {host} every {interval}s.") + while True: + try: + imap = imaplib.IMAP4_SSL(host) + imap.login(user, password) + imap.select(folder) + _, data = imap.search(None, "UNSEEN") + for num in data[0].split(): + _, msg_data = imap.fetch(num, "(RFC822)") + process_message(msg_data[0][1]) + imap.store(num, "+FLAGS", "\\Seen") + imap.logout() + except Exception as e: + print(f"[poller] Error: {e}") + time.sleep(interval) + + +def start(): + t = threading.Thread(target=poll_loop, daemon=True) + t.start() diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..f71bca8 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/static/style.css @@ -0,0 +1,136 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root { + --paper: #fafbfc; + --card: #ffffff; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --radius: 8px; + font-size: 16px; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: #fff; +} +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied, .stamp.rejected { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: #f2f6f8; } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: #e4f3ee; border: 1px solid #bfe0d5; color: #0b5c4c; } +.flash.error { background: #f9e8e6; border: 1px solid #edc7c3; color: #872a24; } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: #f4f6f8; border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..414608f --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — ApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
{{ stats.get('rejected', 0) }}Rejected
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + + + {% else %} + + {% endfor %} +
NameIntake tagAllowed domainApproverStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
+{{ w.slug }} / [{{ w.slug }}]@{{ w.allowed_domain }}{{ w.approver_email }}{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectSourceStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.source }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..7fc20f3 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,35 @@ + + + + + +{% block title %}ApprovalFlow{% endblock %} + + + + + +{% if session.get('user_id') %} +
+ ApprovalFlow + +
+ {{ session['username'] }} + Sign out +
+
+{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..5b5579c --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Decision — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..9978c99 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}New request — ApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..f662b52 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Sign in — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..ed4950d --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — ApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · via {{ req.source }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if req.decided_by %} +

Decided by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'pending' %} +
+ + + Deciding here notifies everyone involved, same as the email links. +
+ {% endif %} +
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..2df58dd --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}Users — ApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.is_admin %}admin{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..f62a879 --- /dev/null +++ b/OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,61 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — ApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + {% if wf %}

Email intake: send to inbox+{{ wf.slug }}@your-domain or put [{{ wf.slug }}] in the subject.

{% endif %} +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {decided_by} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+{% endblock %} diff --git a/OLD_VERSIONS/v2.0/approvalflow.zip b/OLD_VERSIONS/v2.0/approvalflow.zip new file mode 100644 index 0000000..ea8aeb5 Binary files /dev/null and b/OLD_VERSIONS/v2.0/approvalflow.zip differ diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..ed07a41 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/.env.example @@ -0,0 +1,21 @@ +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Inbound email (IMAP) — leave IMAP_HOST empty to disable email intake --- +IMAP_HOST=imap.example.com +IMAP_USER=inbox@example.com +IMAP_PASSWORD=secret +IMAP_FOLDER=INBOX +IMAP_POLL_SECONDS=30 + +# --- Outbound email (SMTP) — leave SMTP_HOST empty to print mail to console (dev) --- +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_SECURITY=starttls # starttls | ssl | none +SMTP_USER=inbox@example.com +SMTP_PASSWORD=secret +SMTP_FROM=approvals@example.com diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..c6d92f7 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/README.md @@ -0,0 +1,84 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests arrive by email (or through a login-protected web form), get redirected to an approver, and once approved or denied, **everyone involved up to that point** is notified by email. Every request keeps a full audit log of everything that happened to it. + +## What it does + +- **Email intake.** An IMAP poller watches a mailbox. Incoming messages become requests and enter the workflow's **approval chain** with one-click **Approve** / **Deny** links. +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester, anyone CC'd on the original email, and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, matched workflow, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, IMAP_*, SMTP_* +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `SMTP_HOST` empty prints outgoing mail to the console and leaving `IMAP_HOST` empty disables email intake — handy for trying the app before wiring up a mailbox. + +## How email intake targets a workflow + +1. **Plus-addressing:** mail sent to `inbox+@your-domain` targets that workflow. +2. **Subject tag:** a subject containing `[]`. +3. **Fallback:** if exactly one active workflow exists, it is used. + +Unmatched mail is ignored. + +## The request lifecycle + +``` +email / web form + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + CCs + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{approve_url}`, `{deny_url}`. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit-log helpers +pipeline.py approval request + decision/notification logic +poller.py IMAP inbox watcher (background thread) +mailer.py SMTP sending + template rendering +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..4df65e7 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/app.py @@ -0,0 +1,298 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash + +import db +import pipeline +import poller +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(request.args.get("next") or url_for("index")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + if not subject or not body: + flash("Subject and message are both required.", "error") + else: + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + requester = user["email"] or user["username"] + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + g.db.commit() + pipeline.send_approval_request(rid) + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.source='web' AND (r.requester=? OR r.requester=?) ORDER BY r.id DESC LIMIT 15", + (session["username"], + (g.db.execute("SELECT email FROM users WHERE id=?", (session["user_id"],)).fetchone()["email"] or "-"), + )).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests) + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + status, req2, message = pipeline.decide(token, action, decided_by) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)") + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() +poller.start() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..1a9582c --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/db.py @@ -0,0 +1,176 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL, -- 'email' or 'web' + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..e0da6b3 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/mailer.py @@ -0,0 +1,54 @@ +"""Outbound email (SMTP) and template rendering.""" +import os +import smtplib +from email.message import EmailMessage + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def send_mail(to_addrs, subject, body): + """Send a plain-text email. Returns (ok, detail).""" + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + host = os.environ.get("SMTP_HOST", "") + port = int(os.environ.get("SMTP_PORT", "587")) + user = os.environ.get("SMTP_USER", "") + password = os.environ.get("SMTP_PASSWORD", "") + sender = os.environ.get("SMTP_FROM", user or "approvalflow@localhost") + security = os.environ.get("SMTP_SECURITY", "starttls").lower() # starttls / ssl / none + + msg = EmailMessage() + msg["From"] = sender + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + msg.set_content(body) + + if not host: + # Dev mode: print instead of sending so the app is testable without SMTP. + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}") + return True, "dev mode (SMTP_HOST not set) — printed to console" + + try: + if security == "ssl": + server = smtplib.SMTP_SSL(host, port, timeout=30) + else: + server = smtplib.SMTP(host, port, timeout=30) + if security == "starttls": + server.starttls() + if user: + server.login(user, password) + server.send_message(msg) + server.quit() + return True, f"sent to {', '.join(to_addrs)}" + except Exception as e: + return False, f"SMTP error: {e}" diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..c5ba954 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,153 @@ +"""Request pipeline shared by the web form and the email poller. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + v = _vars(wf, req) + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + render(wf["approval_body"], **v)) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper()) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v)) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by): + """Handle a decision at the request's current step. + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/poller.py b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/poller.py new file mode 100644 index 0000000..a9da7b5 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/poller.py @@ -0,0 +1,132 @@ +"""IMAP inbox poller. + +Watches a mailbox for new messages and turns them into approval requests. + +Workflow matching, in order: + 1. Plus-addressing: mail sent to inbox+@domain targets that workflow. + 2. Subject tag: a subject containing [] targets that workflow. + 3. Fallback: if exactly one active workflow exists, it is used. + +Unmatched mail is ignored. +""" +import email +import email.utils +import imaplib +import os +import re +import threading +import time +import traceback + +import db +import pipeline + + +def _addr_list(msg, header): + raw = msg.get_all(header, []) + return [a.lower() for _, a in email.utils.getaddresses(raw) if a] + + +def _body_text(msg): + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain" and not part.get("Content-Disposition"): + try: + return part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8", "replace") + except Exception: + continue + return "(no readable text body)" + payload = msg.get_payload(decode=True) + if payload is None: + return str(msg.get_payload()) + return payload.decode(msg.get_content_charset() or "utf-8", "replace") + + +def _match_workflow(con, recipients, subject): + workflows = con.execute("SELECT * FROM workflows WHERE active=1").fetchall() + by_slug = {w["slug"]: w for w in workflows} + + # 1. plus-addressing: inbox+slug@... + for addr in recipients: + m = re.match(r"^[^+@]+\+([a-z0-9\-]+)@", addr) + if m and m.group(1) in by_slug: + return by_slug[m.group(1)], "plus-address" + + # 2. subject tag [slug] + for slug, w in by_slug.items(): + if f"[{slug}]" in subject.lower(): + return w, "subject tag" + + # 3. single active workflow fallback + if len(workflows) == 1: + return workflows[0], "only active workflow" + return None, None + + +def process_message(raw_bytes): + msg = email.message_from_bytes(raw_bytes) + sender = (email.utils.parseaddr(msg.get("From", ""))[1] or "").lower() + recipients = _addr_list(msg, "To") + _addr_list(msg, "Cc") + subject = msg.get("Subject", "(no subject)") + body = _body_text(msg).strip() + + con = db.get_db() + try: + wf, how = _match_workflow(con, recipients, subject) + if wf is None: + print(f"[poller] No workflow matched mail from {sender} — ignored.") + return + + # CC'd people are "involved up to this point" too — everyone on To/Cc + # except the sender and the intake mailbox itself (incl. its +tag variants) + inbox_local = (os.environ.get("IMAP_USER") or "").split("@")[0].lower() + + def is_intake(addr): + base = addr.split("@")[0].split("+")[0] + return (inbox_local and base == inbox_local) or \ + addr.split("@")[0].endswith("+" + wf["slug"]) + + ccs = [a for a in recipients if a != sender and not is_intake(a)] + rid, token = db.create_request(con, wf["id"], "email", sender, subject, body, extra_participants=ccs) + db.log(con, rid, "matched", f"Matched workflow '{wf['name']}' via {how}") + con.commit() + + pipeline.send_approval_request(rid) + print(f"[poller] Request #{rid} created for workflow '{wf['name']}' from {sender}.") + except Exception: + traceback.print_exc() + finally: + con.close() + + +def poll_loop(): + host = os.environ.get("IMAP_HOST", "") + user = os.environ.get("IMAP_USER", "") + password = os.environ.get("IMAP_PASSWORD", "") + folder = os.environ.get("IMAP_FOLDER", "INBOX") + interval = int(os.environ.get("IMAP_POLL_SECONDS", "30")) + + if not host: + print("[poller] IMAP_HOST not set — email intake disabled (web form still works).") + return + + print(f"[poller] Watching {user} on {host} every {interval}s.") + while True: + try: + imap = imaplib.IMAP4_SSL(host) + imap.login(user, password) + imap.select(folder) + _, data = imap.search(None, "UNSEEN") + for num in data[0].split(): + _, msg_data = imap.fetch(num, "(RFC822)") + process_message(msg_data[0][1]) + imap.store(num, "+FLAGS", "\\Seen") + imap.logout() + except Exception as e: + print(f"[poller] Error: {e}") + time.sleep(interval) + + +def start(): + t = threading.Thread(target=poll_loop, daemon=True) + t.start() diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..f71bca8 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/static/style.css @@ -0,0 +1,136 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root { + --paper: #fafbfc; + --card: #ffffff; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --radius: 8px; + font-size: 16px; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: #fff; +} +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied, .stamp.rejected { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: #f2f6f8; } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: #e4f3ee; border: 1px solid #bfe0d5; color: #0b5c4c; } +.flash.error { background: #f9e8e6; border: 1px solid #edc7c3; color: #872a24; } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: #f4f6f8; border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..c617951 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,52 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — ApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
{{ stats.get('rejected', 0) }}Rejected
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + + {% else %} + + {% endfor %} +
NameIntake tagApproval chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
+{{ w.slug }} / [{{ w.slug }}]{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectSourceStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.source }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..7fc20f3 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,35 @@ + + + + + +{% block title %}ApprovalFlow{% endblock %} + + + + + +{% if session.get('user_id') %} +
+ ApprovalFlow + +
+ {{ session['username'] }} + Sign out +
+
+{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..92cc510 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}Decision — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..9978c99 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}New request — ApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..f662b52 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Sign in — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..46c2076 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — ApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · via {{ req.source }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'pending' %} +
+ + + Same effect as the current approver's email links. +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..2df58dd --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}Users — ApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.is_admin %}admin{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..13df989 --- /dev/null +++ b/OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — ApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+ + + + {% if wf %}

Email intake: send to inbox+{{ wf.slug }}@your-domain or put [{{ wf.slug }}] in the subject.

{% endif %} +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+{% endblock %} diff --git a/OLD_VERSIONS/v3.0/approvalflow.zip b/OLD_VERSIONS/v3.0/approvalflow.zip new file mode 100644 index 0000000..55fe614 Binary files /dev/null and b/OLD_VERSIONS/v3.0/approvalflow.zip differ diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..6e37c49 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/.env.example @@ -0,0 +1,13 @@ +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Purelymail account (only login and password are needed) --- +# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS. +# If Two Factor Authentication is enabled on the account, use an App Password. +# Leave MAIL_USER empty to print outgoing mail to the console (dev mode). +MAIL_USER=you@yourdomain.com +MAIL_PASSWORD=secret diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..3809c98 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/README.md @@ -0,0 +1,86 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it. + +Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password. + +## What it does + +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account. + +## Mail server + +The app is preconfigured for Purelymail's servers — you never enter host or port settings: + +| | | +|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS | + +Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password. + +## The request lifecycle + +``` +web form (login required) + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{approve_url}`, `{deny_url}`. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit-log helpers +pipeline.py approval chain + decision/notification logic +mailer.py Purelymail SMTP sending + template rendering +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..454b71c --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/app.py @@ -0,0 +1,296 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash + +import db +import pipeline +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(request.args.get("next") or url_for("index")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + if not subject or not body: + flash("Subject and message are both required.", "error") + else: + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + requester = user["email"] or user["username"] + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + g.db.commit() + pipeline.send_approval_request(rid) + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.source='web' AND (r.requester=? OR r.requester=?) ORDER BY r.id DESC LIMIT 15", + (session["username"], + (g.db.execute("SELECT email FROM users WHERE id=?", (session["user_id"],)).fetchone()["email"] or "-"), + )).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests) + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + status, req2, message = pipeline.decide(token, action, decided_by) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)") + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..b022965 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/db.py @@ -0,0 +1,176 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..33d3e6b --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/mailer.py @@ -0,0 +1,53 @@ +"""Outbound email via Purelymail SMTP, and template rendering. + +The server is preconfigured for Purelymail (smtp.purelymail.com:465, SSL/TLS). +Only the account's email address and password need to be provided: + + MAIL_USER=you@yourdomain.com (or you@purelymail.com) + MAIL_PASSWORD=... (use an App Password if 2FA is enabled) +""" +import os +import smtplib +from email.message import EmailMessage + +SMTP_HOST = "smtp.purelymail.com" +SMTP_PORT = 465 # SSL/TLS + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def send_mail(to_addrs, subject, body): + """Send a plain-text email through Purelymail. Returns (ok, detail).""" + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + user = os.environ.get("MAIL_USER", "") + password = os.environ.get("MAIL_PASSWORD", "") + + msg = EmailMessage() + msg["From"] = user or "approvalflow@localhost" + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + msg.set_content(body) + + if not user: + # Dev mode: print instead of sending so the app is testable without an account. + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}") + return True, "dev mode (MAIL_USER not set) — printed to console" + + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(user, password) + server.send_message(msg) + return True, f"sent to {', '.join(to_addrs)}" + except Exception as e: + return False, f"SMTP error: {e}" diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..cf134a3 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,153 @@ +"""Request pipeline for requests created on the site. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + v = _vars(wf, req) + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + render(wf["approval_body"], **v)) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper()) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v)) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by): + """Handle a decision at the request's current step. + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..f71bca8 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/static/style.css @@ -0,0 +1,136 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root { + --paper: #fafbfc; + --card: #ffffff; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --radius: 8px; + font-size: 16px; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: #fff; +} +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied, .stamp.rejected { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: #f2f6f8; } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: #e4f3ee; border: 1px solid #bfe0d5; color: #0b5c4c; } +.flash.error { background: #f9e8e6; border: 1px solid #edc7c3; color: #872a24; } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: #f4f6f8; border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..6055cf7 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — ApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
{{ stats.get('rejected', 0) }}Rejected
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + {% else %} + + {% endfor %} +
NameApproval chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..7fc20f3 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,35 @@ + + + + + +{% block title %}ApprovalFlow{% endblock %} + + + + + +{% if session.get('user_id') %} +
+ ApprovalFlow + +
+ {{ session['username'] }} + Sign out +
+
+{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..92cc510 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}Decision — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..9978c99 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}New request — ApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..f662b52 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Sign in — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..714a2c8 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — ApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'pending' %} +
+ + + Same effect as the current approver's email links. +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..2df58dd --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}Users — ApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.is_admin %}admin{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..869b6e5 --- /dev/null +++ b/OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — ApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+ + + +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+{% endblock %} diff --git a/OLD_VERSIONS/v4.0 w/approvalflow.zip b/OLD_VERSIONS/v4.0 w/approvalflow.zip new file mode 100644 index 0000000..d0a46cd Binary files /dev/null and b/OLD_VERSIONS/v4.0 w/approvalflow.zip differ diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..6e37c49 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/.env.example @@ -0,0 +1,13 @@ +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Purelymail account (only login and password are needed) --- +# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS. +# If Two Factor Authentication is enabled on the account, use an App Password. +# Leave MAIL_USER empty to print outgoing mail to the console (dev mode). +MAIL_USER=you@yourdomain.com +MAIL_PASSWORD=secret diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..79e7802 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/README.md @@ -0,0 +1,90 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it. + +Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password. + +## What it does + +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account. + +## Mail server + +The app is preconfigured for Purelymail's servers — you never enter host or port settings: + +| | | +|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS | +| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS | + +Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out. + +Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password. + +## The request lifecycle + +``` +web form (login required) + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{approve_url}`, `{deny_url}`. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit/mail-log helpers +pipeline.py approval chain + decision/notification logic +mailer.py Purelymail SMTP sending, Sent-folder copy, mail log +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..9ec9b86 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/app.py @@ -0,0 +1,311 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash + +import db +import pipeline +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(request.args.get("next") or url_for("index")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + if not subject or not body: + flash("Subject and message are both required.", "error") + else: + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + requester = user["email"] or user["username"] + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + g.db.commit() + pipeline.send_approval_request(rid) + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.source='web' AND (r.requester=? OR r.requester=?) ORDER BY r.id DESC LIMIT 15", + (session["username"], + (g.db.execute("SELECT email FROM users WHERE id=?", (session["user_id"],)).fetchone()["email"] or "-"), + )).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests) + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + status, req2, message = pipeline.decide(token, action, decided_by) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)") + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES) + + +# ---------------------------------------------------------------- admin: mail status + +@app.route("/admin/mail") +@admin_required +def mail_status(): + rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall() + stats = {s["status"]: s["c"] for s in + g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")} + copy_fails = g.db.execute( + "SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"] + configured = bool(os.environ.get("MAIL_USER")) + return render_template("mail_status.html", rows=rows, stats=stats, + copy_fails=copy_fails, configured=configured) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..311a381 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/db.py @@ -0,0 +1,189 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS mail_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + request_id INTEGER, -- related request, if any + recipients TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, -- sent / error / dev + detail TEXT DEFAULT '', -- SMTP error message, if any + sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH, timeout=15) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA busy_timeout = 15000") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..9a22444 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/mailer.py @@ -0,0 +1,109 @@ +"""Outbound email via Purelymail, and template rendering. + +Preconfigured for Purelymail — only the account's login and password are needed: + + MAIL_USER=you@yourdomain.com (or you@purelymail.com) + MAIL_PASSWORD=... (use an App Password if 2FA is enabled) + +Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a +copy is saved to the account's Sent folder over IMAP +(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is +recorded in the mail_log table shown on the admin "Mail status" page. +""" +import imaplib +import os +import smtplib +import time +from email.message import EmailMessage + +import db + +SMTP_HOST = "smtp.purelymail.com" +SMTP_PORT = 465 # SSL/TLS +IMAP_HOST = "imap.purelymail.com" +IMAP_PORT = 993 # SSL/TLS + +SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items") + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def _save_to_sent(user, password, msg) -> str: + """Append a copy of the message to the account's Sent folder. Returns a status string.""" + try: + with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap: + imap.login(user, password) + raw = msg.as_bytes() + stamp = imaplib.Time2Internaldate(time.time()) + last_err = "" + for folder in SENT_FOLDER_CANDIDATES: + status, resp = imap.append(folder, r"(\Seen)", stamp, raw) + if status == "OK": + return f"saved to '{folder}'" + last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else "" + # No known folder worked — create "Sent" and try once more. + imap.create("Sent") + status, resp = imap.append("Sent", r"(\Seen)", stamp, raw) + if status == "OK": + return "saved to 'Sent' (folder created)" + return f"failed: {last_err or 'could not append to any Sent folder'}" + except Exception as e: + return f"failed: {e}" + + +def _record(request_id, recipients, subject, status, detail, sent_copy): + try: + con = db.get_db() + con.execute( + "INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)" + " VALUES (?,?,?,?,?,?,?)", + (db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy)) + con.commit() + con.close() + except Exception as e: + print(f"[mail] Could not record mail log entry: {e}") + + +def send_mail(to_addrs, subject, body, request_id=None): + """Send a plain-text email through Purelymail, keep a copy in the Sent + folder, and record the attempt in the mail log. Returns (ok, detail).""" + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + user = os.environ.get("MAIL_USER", "") + password = os.environ.get("MAIL_PASSWORD", "") + + msg = EmailMessage() + msg["From"] = user or "approvalflow@localhost" + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + msg.set_content(body) + + if not user: + # Dev mode: print instead of sending so the app is testable without an account. + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}") + _record(request_id, to_addrs, subject, "dev", + "MAIL_USER not set — printed to console", "skipped (dev mode)") + return True, "dev mode (MAIL_USER not set) — printed to console" + + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(user, password) + server.send_message(msg) + except Exception as e: + detail = f"SMTP error: {e}" + _record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)") + return False, detail + + sent_copy = _save_to_sent(user, password, msg) + _record(request_id, to_addrs, subject, "sent", "", sent_copy) + return True, f"sent to {', '.join(to_addrs)}" diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..0645181 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,158 @@ +"""Request pipeline for requests created on the site. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + con.commit() # release any pending writes before slow mail I/O + v = _vars(wf, req) + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + render(wf["approval_body"], **v), + request_id=request_id) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper()) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v), + request_id=req["id"]) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by): + """Handle a decision at the request's current step. + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + con.commit() + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..f71bca8 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/static/style.css @@ -0,0 +1,136 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root { + --paper: #fafbfc; + --card: #ffffff; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --radius: 8px; + font-size: 16px; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: #fff; +} +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied, .stamp.rejected { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: #f2f6f8; } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: #e4f3ee; border: 1px solid #bfe0d5; color: #0b5c4c; } +.flash.error { background: #f9e8e6; border: 1px solid #edc7c3; color: #872a24; } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: #f4f6f8; border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..6055cf7 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — ApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
{{ stats.get('rejected', 0) }}Rejected
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + {% else %} + + {% endfor %} +
NameApproval chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..e1ceecc --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,36 @@ + + + + + +{% block title %}ApprovalFlow{% endblock %} + + + + + +{% if session.get('user_id') %} +
+ ApprovalFlow + +
+ {{ session['username'] }} + Sign out +
+
+{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..92cc510 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% block title %}Decision — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..9978c99 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}New request — ApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..f662b52 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Sign in — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/mail_status.html b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/mail_status.html new file mode 100644 index 0000000..a1f2bda --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/mail_status.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Mail status — ApprovalFlow{% endblock %} +{% block content %} +

Mail status

+

Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.

+ +{% if not configured %} +
Mail account not configured — MAIL_USER is empty, so messages are printed to the server console (dev mode) instead of being sent.
+{% endif %} + +
+
{{ stats.get('sent', 0) }}Sent
+
{{ stats.get('error', 0) }}Errors
+
{{ copy_fails }}Sent-copy failures
+
{{ stats.get('dev', 0) }}Dev mode
+
+ +
+ + + {% for m in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
WhenToSubjectRequestDeliverySent folder
{{ m.at }}{{ m.recipients }}{{ m.subject }} + {% if m.detail %}
{{ m.detail }}{% endif %}
{% if m.request_id %}#{{ m.request_id }}{% else %}—{% endif %} + {% if m.status == 'sent' %}sent + {% elif m.status == 'error' %}error + {% else %}dev{% endif %} + + {% if m.sent_copy.startswith('saved') %}{{ m.sent_copy }} + {% elif m.sent_copy.startswith('failed') %}copy failed
{{ m.sent_copy }} + {% else %}{{ m.sent_copy }}{% endif %} +
No mail has been sent yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..714a2c8 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,59 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — ApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'pending' %} +
+ + + Same effect as the current approver's email links. +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..2df58dd --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}Users — ApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.is_admin %}admin{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..869b6e5 --- /dev/null +++ b/OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — ApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+ + + +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+{% endblock %} diff --git a/OLD_VERSIONS/v5.0 w/approvalflow.zip b/OLD_VERSIONS/v5.0 w/approvalflow.zip new file mode 100644 index 0000000..a9414d4 Binary files /dev/null and b/OLD_VERSIONS/v5.0 w/approvalflow.zip differ diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..6e37c49 --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/.env.example @@ -0,0 +1,13 @@ +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Purelymail account (only login and password are needed) --- +# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS. +# If Two Factor Authentication is enabled on the account, use an App Password. +# Leave MAIL_USER empty to print outgoing mail to the console (dev mode). +MAIL_USER=you@yourdomain.com +MAIL_PASSWORD=secret diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..492c09c --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/README.md @@ -0,0 +1,90 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it. + +Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password. + +## What it does + +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account. + +## Mail server + +The app is preconfigured for Purelymail's servers — you never enter host or port settings: + +| | | +|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS | +| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS | + +Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out. + +Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password. + +## The request lifecycle + +``` +web form (login required) + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{summary}` (creation + each approval with timestamps, for the approved notice), `{deny_reason}`, `{approve_url}`, `{deny_url}`. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit/mail-log helpers +pipeline.py approval chain + decision/notification logic +mailer.py Purelymail SMTP sending, Sent-folder copy, mail log +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..1c99b7f --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/app.py @@ -0,0 +1,322 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash + +import db +import pipeline +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(request.args.get("next") or url_for("index")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + requester = request.form.get("request_email", "").strip().lower() + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + if not requester or "@" not in requester: + flash("Request's e-mail is required and must be a valid address.", "error") + elif not subject or not body: + flash("Subject and message are both required.", "error") + else: + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + g.db.commit() + pipeline.send_approval_request(rid) + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15", + (session["username"], (user["email"] or "-").lower())).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests, + user_email=user["email"] or "") + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain, + error="A reason for denial is required."), 400 + status, req2, message = pipeline.decide(token, action, decided_by, reason=reason) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + flash("A reason for denial is required.", "error") + return redirect(url_for("admin_request", rid=rid)) + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason) + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES) + + +# ---------------------------------------------------------------- admin: mail status + +@app.route("/admin/mail") +@admin_required +def mail_status(): + rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall() + stats = {s["status"]: s["c"] for s in + g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")} + copy_fails = g.db.execute( + "SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"] + configured = bool(os.environ.get("MAIL_USER")) + return render_template("mail_status.html", rows=rows, stats=stats, + copy_fails=copy_fails, configured=configured) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..8b7ea9d --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/db.py @@ -0,0 +1,204 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + deny_reason TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL REFERENCES requests(id) ON DELETE CASCADE, + step INTEGER NOT NULL, -- 1-based position in the chain + decided_by TEXT NOT NULL, + decision TEXT NOT NULL, -- approved / denied + at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS mail_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + request_id INTEGER, -- related request, if any + recipients TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, -- sent / error / dev + detail TEXT DEFAULT '', -- SMTP error message, if any + sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Summary:\n{summary}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Reason for denial:\n{deny_reason}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH, timeout=15) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA busy_timeout = 15000") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + if rcols and "deny_reason" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN deny_reason TEXT NOT NULL DEFAULT ''") + print("[migrate] Added deny_reason to requests.") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..9a22444 --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/mailer.py @@ -0,0 +1,109 @@ +"""Outbound email via Purelymail, and template rendering. + +Preconfigured for Purelymail — only the account's login and password are needed: + + MAIL_USER=you@yourdomain.com (or you@purelymail.com) + MAIL_PASSWORD=... (use an App Password if 2FA is enabled) + +Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a +copy is saved to the account's Sent folder over IMAP +(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is +recorded in the mail_log table shown on the admin "Mail status" page. +""" +import imaplib +import os +import smtplib +import time +from email.message import EmailMessage + +import db + +SMTP_HOST = "smtp.purelymail.com" +SMTP_PORT = 465 # SSL/TLS +IMAP_HOST = "imap.purelymail.com" +IMAP_PORT = 993 # SSL/TLS + +SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items") + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def _save_to_sent(user, password, msg) -> str: + """Append a copy of the message to the account's Sent folder. Returns a status string.""" + try: + with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap: + imap.login(user, password) + raw = msg.as_bytes() + stamp = imaplib.Time2Internaldate(time.time()) + last_err = "" + for folder in SENT_FOLDER_CANDIDATES: + status, resp = imap.append(folder, r"(\Seen)", stamp, raw) + if status == "OK": + return f"saved to '{folder}'" + last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else "" + # No known folder worked — create "Sent" and try once more. + imap.create("Sent") + status, resp = imap.append("Sent", r"(\Seen)", stamp, raw) + if status == "OK": + return "saved to 'Sent' (folder created)" + return f"failed: {last_err or 'could not append to any Sent folder'}" + except Exception as e: + return f"failed: {e}" + + +def _record(request_id, recipients, subject, status, detail, sent_copy): + try: + con = db.get_db() + con.execute( + "INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)" + " VALUES (?,?,?,?,?,?,?)", + (db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy)) + con.commit() + con.close() + except Exception as e: + print(f"[mail] Could not record mail log entry: {e}") + + +def send_mail(to_addrs, subject, body, request_id=None): + """Send a plain-text email through Purelymail, keep a copy in the Sent + folder, and record the attempt in the mail log. Returns (ok, detail).""" + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + user = os.environ.get("MAIL_USER", "") + password = os.environ.get("MAIL_PASSWORD", "") + + msg = EmailMessage() + msg["From"] = user or "approvalflow@localhost" + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + msg.set_content(body) + + if not user: + # Dev mode: print instead of sending so the app is testable without an account. + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}") + _record(request_id, to_addrs, subject, "dev", + "MAIL_USER not set — printed to console", "skipped (dev mode)") + return True, "dev mode (MAIL_USER not set) — printed to console" + + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(user, password) + server.send_message(msg) + except Exception as e: + detail = f"SMTP error: {e}" + _record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)") + return False, detail + + sent_copy = _save_to_sent(user, password, msg) + _record(request_id, to_addrs, subject, "sent", "", sent_copy) + return True, f"sent to {', '.join(to_addrs)}" diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..4264b1e --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,189 @@ +"""Request pipeline for requests created on the site. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def _record_decision(con, request_id, step, decided_by, decision): + con.execute("INSERT INTO decisions (request_id, step, decided_by, decision, at) VALUES (?,?,?,?,?)", + (request_id, step, decided_by, decision, db.now())) + + +def build_summary(con, req, total_steps): + """Human-readable history: when the request was created and who + approved (or denied) each step, with timestamps.""" + lines = [f" Created {req['created_at']} by {req['requester']}"] + for d in con.execute("SELECT * FROM decisions WHERE request_id=? ORDER BY id", (req["id"],)): + verb = "Approved" if d["decision"] == "approved" else "DENIED" + lines.append(f" Step {d['step']}/{total_steps} {verb} {d['at']} by {d['decided_by']}") + return "\n".join(lines) + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + con.commit() # release any pending writes before slow mail I/O + v = _vars(wf, req) + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + render(wf["approval_body"], **v), + request_id=request_id) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + summary = build_summary(con, req, len(approver_chain(wf))) + con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper(), + summary=summary, deny_reason=req["deny_reason"]) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + # These are mandatory in the outcome notice: append them if the workflow's + # custom template doesn't include the placeholder. + if new_status == "approved" and "{summary}" not in body_t: + body_t += "\n\nSummary:\n{summary}\n" + if new_status == "denied" and "{deny_reason}" not in body_t: + body_t += "\n\nReason for denial:\n{deny_reason}\n" + ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v), + request_id=req["id"]) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by, reason=""): + """Handle a decision at the request's current step. + + A denial requires a reason (callers enforce it as a mandatory field; + this is a second line of defense). + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + reason = (reason or "").strip() + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + if not reason: + return "needreason", req, "A reason for denial is required." + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=?, deny_reason=? WHERE id=?", + (decided_by, db.now(), reason, req["id"])) + _record_decision(con, req["id"], step + 1, decided_by, "denied") + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped. Reason: {reason}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + _record_decision(con, req["id"], step + 1, decided_by, "approved") + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + con.commit() + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..f71bca8 --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/static/style.css @@ -0,0 +1,136 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root { + --paper: #fafbfc; + --card: #ffffff; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --radius: 8px; + font-size: 16px; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: #fff; +} +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied, .stamp.rejected { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: #f2f6f8; } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: #e4f3ee; border: 1px solid #bfe0d5; color: #0b5c4c; } +.flash.error { background: #f9e8e6; border: 1px solid #edc7c3; color: #872a24; } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: #f4f6f8; border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..6055cf7 --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — ApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
{{ stats.get('rejected', 0) }}Rejected
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + {% else %} + + {% endfor %} +
NameApproval chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..e1ceecc --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,36 @@ + + + + + +{% block title %}ApprovalFlow{% endblock %} + + + + + +{% if session.get('user_id') %} +
+ ApprovalFlow + +
+ {{ session['username'] }} + Sign out +
+
+{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..9f5391a --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Decision — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..35c4a26 --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}New request — ApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..f662b52 --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Sign in — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/mail_status.html b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/mail_status.html new file mode 100644 index 0000000..a1f2bda --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/mail_status.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Mail status — ApprovalFlow{% endblock %} +{% block content %} +

Mail status

+

Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.

+ +{% if not configured %} +
Mail account not configured — MAIL_USER is empty, so messages are printed to the server console (dev mode) instead of being sent.
+{% endif %} + +
+
{{ stats.get('sent', 0) }}Sent
+
{{ stats.get('error', 0) }}Errors
+
{{ copy_fails }}Sent-copy failures
+
{{ stats.get('dev', 0) }}Dev mode
+
+ +
+ + + {% for m in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
WhenToSubjectRequestDeliverySent folder
{{ m.at }}{{ m.recipients }}{{ m.subject }} + {% if m.detail %}
{{ m.detail }}{% endif %}
{% if m.request_id %}#{{ m.request_id }}{% else %}—{% endif %} + {% if m.status == 'sent' %}sent + {% elif m.status == 'error' %}error + {% else %}dev{% endif %} + + {% if m.sent_copy.startswith('saved') %}{{ m.sent_copy }} + {% elif m.sent_copy.startswith('failed') %}copy failed
{{ m.sent_copy }} + {% else %}{{ m.sent_copy }}{% endif %} +
No mail has been sent yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..bf022c8 --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — ApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'denied' and req.deny_reason %} +

Reason for denial:

+
{{ req.deny_reason }}
+ {% endif %} + {% if req.status == 'pending' %} +
+
+ + Same effect as the current approver's email links. +
+
+
+ + + +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..2df58dd --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}Users — ApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.is_admin %}admin{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..63776d5 --- /dev/null +++ b/OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — ApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+ + + +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {summary} {deny_reason} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+{% endblock %} diff --git a/OLD_VERSIONS/v6.0 w/approvalflow.zip b/OLD_VERSIONS/v6.0 w/approvalflow.zip new file mode 100644 index 0000000..e312724 Binary files /dev/null and b/OLD_VERSIONS/v6.0 w/approvalflow.zip differ diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..6e37c49 --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/.env.example @@ -0,0 +1,13 @@ +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Purelymail account (only login and password are needed) --- +# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS. +# If Two Factor Authentication is enabled on the account, use an App Password. +# Leave MAIL_USER empty to print outgoing mail to the console (dev mode). +MAIL_USER=you@yourdomain.com +MAIL_PASSWORD=secret diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..492c09c --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/README.md @@ -0,0 +1,90 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it. + +Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password. + +## What it does + +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account. + +## Mail server + +The app is preconfigured for Purelymail's servers — you never enter host or port settings: + +| | | +|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS | +| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS | + +Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out. + +Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password. + +## The request lifecycle + +``` +web form (login required) + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{summary}` (creation + each approval with timestamps, for the approved notice), `{deny_reason}`, `{approve_url}`, `{deny_url}`. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit/mail-log helpers +pipeline.py approval chain + decision/notification logic +mailer.py Purelymail SMTP sending, Sent-folder copy, mail log +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..fb633a5 --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/app.py @@ -0,0 +1,377 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash + +import db +import pipeline +from mailer import send_mail +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(request.args.get("next") or url_for("index")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + requester = request.form.get("request_email", "").strip().lower() + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + if not requester or "@" not in requester: + flash("Request's e-mail is required and must be a valid address.", "error") + elif not subject or not body: + flash("Subject and message are both required.", "error") + else: + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + g.db.commit() + pipeline.send_approval_request(rid) + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15", + (session["username"], (user["email"] or "-").lower())).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests, + user_email=user["email"] or "") + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain, + error="A reason for denial is required."), 400 + status, req2, message = pipeline.decide(token, action, decided_by, reason=reason) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + flash("A reason for denial is required.", "error") + return redirect(url_for("admin_request", rid=rid)) + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason) + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain) + + +@app.route("/admin/request//send-log", methods=["POST"]) +@admin_required +def send_request_log(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + to = request.form.get("email", "").strip() + if not to or "@" not in to: + flash("A valid e-mail address is required to send the log.", "error") + return redirect(url_for("admin_request", rid=rid)) + + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + chain = pipeline.approver_chain(req) + participants = json.loads(req["participants"]) + + lines = [ + f"Full log of request #{req['id']} — {req['subject']}", + "=" * 60, + f"Workflow: {req['wf_name']}", + f"Requester: {req['requester']}", + f"Created: {req['created_at']}", + f"Status: {req['status'].upper()}", + ] + if req["decided_by"]: + lines.append(f"Final decision: by {req['decided_by']} at {req['decided_at']}") + if req["deny_reason"]: + lines.append(f"Denial reason: {req['deny_reason']}") + lines += [ + f"Approval chain: {' -> '.join(chain)}", + f"People involved: {', '.join(participants)}", + "", + "Message:", + req["body"], + "", + "Event log:", + "-" * 60, + ] + for l in logs: + lines.append(f"{l['at']} | {l['event']}") + if l["detail"]: + lines.append(f"{'':25} {l['detail']}") + body = "\n".join(lines) + + ok, detail = send_mail(to, f"[{req['wf_name']}] Full log of request #{req['id']}: {req['subject']}", + body, request_id=rid) + db.log(g.db, rid, "log sent" if ok else "mail error", + f"Full log emailed to {to} by {session['username']} — {detail}") + g.db.commit() + flash(f"Full log sent to {to}." if ok else f"Could not send the log: {detail}", + "ok" if ok else "error") + return redirect(url_for("admin_request", rid=rid)) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES) + + +# ---------------------------------------------------------------- admin: mail status + +@app.route("/admin/mail") +@admin_required +def mail_status(): + rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall() + stats = {s["status"]: s["c"] for s in + g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")} + copy_fails = g.db.execute( + "SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"] + configured = bool(os.environ.get("MAIL_USER")) + return render_template("mail_status.html", rows=rows, stats=stats, + copy_fails=copy_fails, configured=configured) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..b182235 --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/db.py @@ -0,0 +1,207 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + deny_reason TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL REFERENCES requests(id) ON DELETE CASCADE, + step INTEGER NOT NULL, -- 1-based position in the chain + decided_by TEXT NOT NULL, + decision TEXT NOT NULL, -- approved / denied + at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS mail_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + request_id INTEGER, -- related request, if any + recipients TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, -- sent / error / dev + detail TEXT DEFAULT '', -- SMTP error message, if any + sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Summary:\n{summary}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Reason for denial:\n{deny_reason}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH, timeout=15) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA busy_timeout = 15000") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + if rcols and "deny_reason" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN deny_reason TEXT NOT NULL DEFAULT ''") + print("[migrate] Added deny_reason to requests.") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..9a22444 --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/mailer.py @@ -0,0 +1,109 @@ +"""Outbound email via Purelymail, and template rendering. + +Preconfigured for Purelymail — only the account's login and password are needed: + + MAIL_USER=you@yourdomain.com (or you@purelymail.com) + MAIL_PASSWORD=... (use an App Password if 2FA is enabled) + +Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a +copy is saved to the account's Sent folder over IMAP +(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is +recorded in the mail_log table shown on the admin "Mail status" page. +""" +import imaplib +import os +import smtplib +import time +from email.message import EmailMessage + +import db + +SMTP_HOST = "smtp.purelymail.com" +SMTP_PORT = 465 # SSL/TLS +IMAP_HOST = "imap.purelymail.com" +IMAP_PORT = 993 # SSL/TLS + +SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items") + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def _save_to_sent(user, password, msg) -> str: + """Append a copy of the message to the account's Sent folder. Returns a status string.""" + try: + with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap: + imap.login(user, password) + raw = msg.as_bytes() + stamp = imaplib.Time2Internaldate(time.time()) + last_err = "" + for folder in SENT_FOLDER_CANDIDATES: + status, resp = imap.append(folder, r"(\Seen)", stamp, raw) + if status == "OK": + return f"saved to '{folder}'" + last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else "" + # No known folder worked — create "Sent" and try once more. + imap.create("Sent") + status, resp = imap.append("Sent", r"(\Seen)", stamp, raw) + if status == "OK": + return "saved to 'Sent' (folder created)" + return f"failed: {last_err or 'could not append to any Sent folder'}" + except Exception as e: + return f"failed: {e}" + + +def _record(request_id, recipients, subject, status, detail, sent_copy): + try: + con = db.get_db() + con.execute( + "INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)" + " VALUES (?,?,?,?,?,?,?)", + (db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy)) + con.commit() + con.close() + except Exception as e: + print(f"[mail] Could not record mail log entry: {e}") + + +def send_mail(to_addrs, subject, body, request_id=None): + """Send a plain-text email through Purelymail, keep a copy in the Sent + folder, and record the attempt in the mail log. Returns (ok, detail).""" + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + user = os.environ.get("MAIL_USER", "") + password = os.environ.get("MAIL_PASSWORD", "") + + msg = EmailMessage() + msg["From"] = user or "approvalflow@localhost" + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + msg.set_content(body) + + if not user: + # Dev mode: print instead of sending so the app is testable without an account. + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}\n{'-'*50}") + _record(request_id, to_addrs, subject, "dev", + "MAIL_USER not set — printed to console", "skipped (dev mode)") + return True, "dev mode (MAIL_USER not set) — printed to console" + + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(user, password) + server.send_message(msg) + except Exception as e: + detail = f"SMTP error: {e}" + _record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)") + return False, detail + + sent_copy = _save_to_sent(user, password, msg) + _record(request_id, to_addrs, subject, "sent", "", sent_copy) + return True, f"sent to {', '.join(to_addrs)}" diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..e63850d --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,196 @@ +"""Request pipeline for requests created on the site. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "created_at": req["created_at"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def _record_decision(con, request_id, step, decided_by, decision): + con.execute("INSERT INTO decisions (request_id, step, decided_by, decision, at) VALUES (?,?,?,?,?)", + (request_id, step, decided_by, decision, db.now())) + + +def build_summary(con, req, total_steps): + """Human-readable history: when the request was created and who + approved (or denied) each step, with timestamps.""" + lines = [f" Created {req['created_at']} by {req['requester']}"] + for d in con.execute("SELECT * FROM decisions WHERE request_id=? ORDER BY id", (req["id"],)): + verb = "Approved" if d["decision"] == "approved" else "DENIED" + lines.append(f" Step {d['step']}/{total_steps} {verb} {d['at']} by {d['decided_by']}") + return "\n".join(lines) + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + con.commit() # release any pending writes before slow mail I/O + v = _vars(wf, req) + body_t = wf["approval_body"] + if "{created_at}" not in body_t: # creation time is mandatory in every mail + body_t = "Request created: {created_at}\n\n" + body_t + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + render(body_t, **v), + request_id=request_id) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + summary = build_summary(con, req, len(approver_chain(wf))) + con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper(), + summary=summary, deny_reason=req["deny_reason"]) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + # These are mandatory in the outcome notice: append them if the workflow's + # custom template doesn't include the placeholder. + if new_status == "approved" and "{summary}" not in body_t: + body_t += "\n\nSummary:\n{summary}\n" + if new_status == "denied" and "{deny_reason}" not in body_t: + body_t += "\n\nReason for denial:\n{deny_reason}\n" + if "{created_at}" not in body_t and "{summary}" not in body_t: + # creation time is mandatory in every mail ({summary} already includes it) + body_t = "Request created: {created_at}\n\n" + body_t + ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v), + request_id=req["id"]) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by, reason=""): + """Handle a decision at the request's current step. + + A denial requires a reason (callers enforce it as a mandatory field; + this is a second line of defense). + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + reason = (reason or "").strip() + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + if not reason: + return "needreason", req, "A reason for denial is required." + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=?, deny_reason=? WHERE id=?", + (decided_by, db.now(), reason, req["id"])) + _record_decision(con, req["id"], step + 1, decided_by, "denied") + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped. Reason: {reason}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + _record_decision(con, req["id"], step + 1, decided_by, "approved") + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + con.commit() + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..ae26b67 --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/static/style.css @@ -0,0 +1,136 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root { + --paper: #fafbfc; + --card: #ffffff; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --radius: 8px; + font-size: 16px; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: #fff; +} +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: #f2f6f8; } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: #e4f3ee; border: 1px solid #bfe0d5; color: #0b5c4c; } +.flash.error { background: #f9e8e6; border: 1px solid #edc7c3; color: #872a24; } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: #f4f6f8; border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..9163bca --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — ApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + {% else %} + + {% endfor %} +
NameApproval chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..e1ceecc --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,36 @@ + + + + + +{% block title %}ApprovalFlow{% endblock %} + + + + + +{% if session.get('user_id') %} +
+ ApprovalFlow + +
+ {{ session['username'] }} + Sign out +
+
+{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+ + diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..9f5391a --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}Decision — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..35c4a26 --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} +{% block title %}New request — ApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..f662b52 --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Sign in — ApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/mail_status.html b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/mail_status.html new file mode 100644 index 0000000..a1f2bda --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/mail_status.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Mail status — ApprovalFlow{% endblock %} +{% block content %} +

Mail status

+

Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.

+ +{% if not configured %} +
Mail account not configured — MAIL_USER is empty, so messages are printed to the server console (dev mode) instead of being sent.
+{% endif %} + +
+
{{ stats.get('sent', 0) }}Sent
+
{{ stats.get('error', 0) }}Errors
+
{{ copy_fails }}Sent-copy failures
+
{{ stats.get('dev', 0) }}Dev mode
+
+ +
+ + + {% for m in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
WhenToSubjectRequestDeliverySent folder
{{ m.at }}{{ m.recipients }}{{ m.subject }} + {% if m.detail %}
{{ m.detail }}{% endif %}
{% if m.request_id %}#{{ m.request_id }}{% else %}—{% endif %} + {% if m.status == 'sent' %}sent + {% elif m.status == 'error' %}error + {% else %}dev{% endif %} + + {% if m.sent_copy.startswith('saved') %}{{ m.sent_copy }} + {% elif m.sent_copy.startswith('failed') %}copy failed
{{ m.sent_copy }} + {% else %}{{ m.sent_copy }}{% endif %} +
No mail has been sent yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..91d4afe --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — ApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'denied' and req.deny_reason %} +

Reason for denial:

+
{{ req.deny_reason }}
+ {% endif %} + {% if req.status == 'pending' %} +
+
+ + Same effect as the current approver's email links. +
+
+
+ + + +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+ + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..2df58dd --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}Users — ApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.is_admin %}admin{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..06b1a3c --- /dev/null +++ b/OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — ApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+ + + +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {created_at} {summary} {deny_reason} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+{% endblock %} diff --git a/OLD_VERSIONS/v7.0 w/approvalflow.zip b/OLD_VERSIONS/v7.0 w/approvalflow.zip new file mode 100644 index 0000000..4be3a95 Binary files /dev/null and b/OLD_VERSIONS/v7.0 w/approvalflow.zip differ diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..6e37c49 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/.env.example @@ -0,0 +1,13 @@ +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Purelymail account (only login and password are needed) --- +# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS. +# If Two Factor Authentication is enabled on the account, use an App Password. +# Leave MAIL_USER empty to print outgoing mail to the console (dev mode). +MAIL_USER=you@yourdomain.com +MAIL_PASSWORD=secret diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..d7dd830 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/README.md @@ -0,0 +1,91 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it. + +Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password. + +## What it does + +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Attachments.** A request can include one uploaded file (up to 15 MB). It is attached to **every approval mail** sent along the chain, and admins can download it from the request page. Files are stored next to the database in `data/attachments/`. +- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account. + +## Mail server + +The app is preconfigured for Purelymail's servers — you never enter host or port settings: + +| | | +|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS | +| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS | + +Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out. + +Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password. + +## The request lifecycle + +``` +web form (login required) + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{summary}` (creation + each approval with timestamps, for the approved notice), `{deny_reason}`, `{approve_url}`, `{deny_url}`. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit/mail-log helpers +pipeline.py approval chain + decision/notification logic +mailer.py Purelymail SMTP sending, Sent-folder copy, mail log +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..223876f --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/app.py @@ -0,0 +1,411 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, send_file, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +import db +import pipeline +from mailer import send_mail +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) +# 15 MB attachment limit (+ small allowance for the rest of the form) +app.config["MAX_CONTENT_LENGTH"] = db.MAX_ATTACHMENT_BYTES + 512 * 1024 + + +@app.errorhandler(413) +def too_large(e): + flash("The uploaded file is too large — the limit is 15 MB.", "error") + return redirect(url_for("index")) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(request.args.get("next") or url_for("index")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + requester = request.form.get("request_email", "").strip().lower() + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + upload = request.files.get("attachment") + file_data = upload.read() if upload and upload.filename else b"" + if not requester or "@" not in requester: + flash("Request's e-mail is required and must be a valid address.", "error") + elif not subject or not body: + flash("Subject and message are both required.", "error") + elif len(file_data) > db.MAX_ATTACHMENT_BYTES: + flash("The uploaded file is too large — the limit is 15 MB.", "error") + else: + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + if file_data: + stored = f"{rid}_{secure_filename(upload.filename) or 'attachment'}" + os.makedirs(db.ATTACH_DIR, exist_ok=True) + with open(db.attachment_path(stored), "wb") as fh: + fh.write(file_data) + g.db.execute("UPDATE requests SET attachment=? WHERE id=?", (stored, rid)) + db.log(g.db, rid, "attachment saved", + f"'{upload.filename}' ({len(file_data)} bytes) — included in every approval mail") + g.db.commit() + pipeline.send_approval_request(rid) + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15", + (session["username"], (user["email"] or "-").lower())).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests, + user_email=user["email"] or "") + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain, + error="A reason for denial is required."), 400 + status, req2, message = pipeline.decide(token, action, decided_by, reason=reason) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + flash("A reason for denial is required.", "error") + return redirect(url_for("admin_request", rid=rid)) + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason) + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain) + + +@app.route("/admin/request//attachment") +@admin_required +def request_attachment(rid): + req = g.db.execute("SELECT attachment FROM requests WHERE id=?", (rid,)).fetchone() + if req is None or not req["attachment"]: + abort(404) + path = db.attachment_path(req["attachment"]) + if not os.path.exists(path): + abort(404) + return send_file(path, as_attachment=True, + download_name=req["attachment"].split("_", 1)[-1]) + + +@app.route("/admin/request//send-log", methods=["POST"]) +@admin_required +def send_request_log(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + to = request.form.get("email", "").strip() + if not to or "@" not in to: + flash("A valid e-mail address is required to send the log.", "error") + return redirect(url_for("admin_request", rid=rid)) + + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + chain = pipeline.approver_chain(req) + participants = json.loads(req["participants"]) + + lines = [ + f"Full log of request #{req['id']} — {req['subject']}", + "=" * 60, + f"Workflow: {req['wf_name']}", + f"Requester: {req['requester']}", + f"Created: {req['created_at']}", + f"Status: {req['status'].upper()}", + ] + if req["decided_by"]: + lines.append(f"Final decision: by {req['decided_by']} at {req['decided_at']}") + if req["deny_reason"]: + lines.append(f"Denial reason: {req['deny_reason']}") + lines += [ + f"Approval chain: {' -> '.join(chain)}", + f"People involved: {', '.join(participants)}", + "", + "Message:", + req["body"], + "", + "Event log:", + "-" * 60, + ] + for l in logs: + lines.append(f"{l['at']} | {l['event']}") + if l["detail"]: + lines.append(f"{'':25} {l['detail']}") + body = "\n".join(lines) + + ok, detail = send_mail(to, f"[{req['wf_name']}] Full log of request #{req['id']}: {req['subject']}", + body, request_id=rid) + db.log(g.db, rid, "log sent" if ok else "mail error", + f"Full log emailed to {to} by {session['username']} — {detail}") + g.db.commit() + flash(f"Full log sent to {to}." if ok else f"Could not send the log: {detail}", + "ok" if ok else "error") + return redirect(url_for("admin_request", rid=rid)) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES) + + +# ---------------------------------------------------------------- admin: mail status + +@app.route("/admin/mail") +@admin_required +def mail_status(): + rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall() + stats = {s["status"]: s["c"] for s in + g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")} + copy_fails = g.db.execute( + "SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"] + configured = bool(os.environ.get("MAIL_USER")) + return render_template("mail_status.html", rows=rows, stats=stats, + copy_fails=copy_fails, configured=configured) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..eb4f085 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/db.py @@ -0,0 +1,218 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) +ATTACH_DIR = os.path.join(os.path.dirname(DB_PATH), "attachments") + +MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024 # 15 MB + + +def attachment_path(stored_name): + return os.path.join(ATTACH_DIR, stored_name) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + deny_reason TEXT NOT NULL DEFAULT '', + attachment TEXT NOT NULL DEFAULT '', -- stored filename of the uploaded file, if any + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL REFERENCES requests(id) ON DELETE CASCADE, + step INTEGER NOT NULL, -- 1-based position in the chain + decided_by TEXT NOT NULL, + decision TEXT NOT NULL, -- approved / denied + at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS mail_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + request_id INTEGER, -- related request, if any + recipients TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, -- sent / error / dev + detail TEXT DEFAULT '', -- SMTP error message, if any + sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Summary:\n{summary}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Reason for denial:\n{deny_reason}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH, timeout=15) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA busy_timeout = 15000") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + if rcols and "deny_reason" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN deny_reason TEXT NOT NULL DEFAULT ''") + print("[migrate] Added deny_reason to requests.") + if rcols and "attachment" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN attachment TEXT NOT NULL DEFAULT ''") + print("[migrate] Added attachment to requests.") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..24033e3 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/mailer.py @@ -0,0 +1,115 @@ +"""Outbound email via Purelymail, and template rendering. + +Preconfigured for Purelymail — only the account's login and password are needed: + + MAIL_USER=you@yourdomain.com (or you@purelymail.com) + MAIL_PASSWORD=... (use an App Password if 2FA is enabled) + +Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a +copy is saved to the account's Sent folder over IMAP +(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is +recorded in the mail_log table shown on the admin "Mail status" page. +""" +import imaplib +import os +import smtplib +import time +from email.message import EmailMessage + +import db + +SMTP_HOST = "smtp.purelymail.com" +SMTP_PORT = 465 # SSL/TLS +IMAP_HOST = "imap.purelymail.com" +IMAP_PORT = 993 # SSL/TLS + +SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items") + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def _save_to_sent(user, password, msg) -> str: + """Append a copy of the message to the account's Sent folder. Returns a status string.""" + try: + with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap: + imap.login(user, password) + raw = msg.as_bytes() + stamp = imaplib.Time2Internaldate(time.time()) + last_err = "" + for folder in SENT_FOLDER_CANDIDATES: + status, resp = imap.append(folder, r"(\Seen)", stamp, raw) + if status == "OK": + return f"saved to '{folder}'" + last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else "" + # No known folder worked — create "Sent" and try once more. + imap.create("Sent") + status, resp = imap.append("Sent", r"(\Seen)", stamp, raw) + if status == "OK": + return "saved to 'Sent' (folder created)" + return f"failed: {last_err or 'could not append to any Sent folder'}" + except Exception as e: + return f"failed: {e}" + + +def _record(request_id, recipients, subject, status, detail, sent_copy): + try: + con = db.get_db() + con.execute( + "INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)" + " VALUES (?,?,?,?,?,?,?)", + (db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy)) + con.commit() + con.close() + except Exception as e: + print(f"[mail] Could not record mail log entry: {e}") + + +def send_mail(to_addrs, subject, body, request_id=None, attachments=None): + """Send an email through Purelymail, keep a copy in the Sent folder, and + record the attempt in the mail log. Returns (ok, detail). + + attachments: optional list of (filename, bytes, maintype, subtype). + """ + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + user = os.environ.get("MAIL_USER", "") + password = os.environ.get("MAIL_PASSWORD", "") + + msg = EmailMessage() + msg["From"] = user or "approvalflow@localhost" + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + msg.set_content(body) + for filename, data, maintype, subtype in (attachments or []): + msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) + + if not user: + # Dev mode: print instead of sending so the app is testable without an account. + att = "".join(f"\n[attachment: {f} ({len(b)} bytes)]" for f, b, *_ in (attachments or [])) + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}{att}\n{'-'*50}") + _record(request_id, to_addrs, subject, "dev", + "MAIL_USER not set — printed to console", "skipped (dev mode)") + return True, "dev mode (MAIL_USER not set) — printed to console" + + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(user, password) + server.send_message(msg) + except Exception as e: + detail = f"SMTP error: {e}" + _record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)") + return False, detail + + sent_copy = _save_to_sent(user, password, msg) + _record(request_id, to_addrs, subject, "sent", "", sent_copy) + return True, f"sent to {', '.join(to_addrs)}" diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..f516f33 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,216 @@ +"""Request pipeline for requests created on the site. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import mimetypes +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "created_at": req["created_at"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def _record_decision(con, request_id, step, decided_by, decision): + con.execute("INSERT INTO decisions (request_id, step, decided_by, decision, at) VALUES (?,?,?,?,?)", + (request_id, step, decided_by, decision, db.now())) + + +def build_summary(con, req, total_steps): + """Human-readable history: when the request was created and who + approved (or denied) each step, with timestamps.""" + lines = [f" Created {req['created_at']} by {req['requester']}"] + for d in con.execute("SELECT * FROM decisions WHERE request_id=? ORDER BY id", (req["id"],)): + verb = "Approved" if d["decision"] == "approved" else "DENIED" + lines.append(f" Step {d['step']}/{total_steps} {verb} {d['at']} by {d['decided_by']}") + return "\n".join(lines) + + +def load_attachment(req): + """Load the request's uploaded file (if any) as a send_mail attachment list.""" + if not req["attachment"]: + return None + path = db.attachment_path(req["attachment"]) + try: + with open(path, "rb") as f: + data = f.read() + except OSError: + return None + # the stored name is "_"; show the original name in the mail + original = req["attachment"].split("_", 1)[-1] + ctype = mimetypes.guess_type(original)[0] or "application/octet-stream" + maintype, subtype = ctype.split("/", 1) + return [(original, data, maintype, subtype)] + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + con.commit() # release any pending writes before slow mail I/O + v = _vars(wf, req) + body_t = wf["approval_body"] + if "{created_at}" not in body_t: # creation time is mandatory in every mail + body_t = "Request created: {created_at}\n\n" + body_t + attachments = load_attachment(req) + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + render(body_t, **v), + request_id=request_id, + attachments=attachments) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + summary = build_summary(con, req, len(approver_chain(wf))) + con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper(), + summary=summary, deny_reason=req["deny_reason"]) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + # These are mandatory in the outcome notice: append them if the workflow's + # custom template doesn't include the placeholder. + if new_status == "approved" and "{summary}" not in body_t: + body_t += "\n\nSummary:\n{summary}\n" + if new_status == "denied" and "{deny_reason}" not in body_t: + body_t += "\n\nReason for denial:\n{deny_reason}\n" + if "{created_at}" not in body_t and "{summary}" not in body_t: + # creation time is mandatory in every mail ({summary} already includes it) + body_t = "Request created: {created_at}\n\n" + body_t + ok, detail = send_mail(participants, render(subj_t, **v), render(body_t, **v), + request_id=req["id"]) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by, reason=""): + """Handle a decision at the request's current step. + + A denial requires a reason (callers enforce it as a mandatory field; + this is a second line of defense). + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + reason = (reason or "").strip() + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + if not reason: + return "needreason", req, "A reason for denial is required." + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=?, deny_reason=? WHERE id=?", + (decided_by, db.now(), reason, req["id"])) + _record_decision(con, req["id"], step + 1, decided_by, "denied") + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped. Reason: {reason}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + _record_decision(con, req["id"], step + 1, decided_by, "approved") + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + con.commit() + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..16fec52 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/static/style.css @@ -0,0 +1,143 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root { + --paper: #fafbfc; + --card: #ffffff; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --radius: 8px; + font-size: 16px; +} +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: #fff; +} +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: #f2f6f8; } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: #e4f3ee; border: 1px solid #bfe0d5; color: #0b5c4c; } +.flash.error { background: #f9e8e6; border: 1px solid #edc7c3; color: #872a24; } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: #f4f6f8; border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} + +/* footer */ +.footer { + max-width: 980px; margin: 0 auto; padding: 1.2rem 1.25rem 2rem; + font-size: .8rem; color: var(--ink-soft); text-align: center; + border-top: 1px solid var(--line); +} diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..98913ab --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + {% else %} + + {% endfor %} +
NameApproval chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..eb8b41a --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,37 @@ + + + + + +{% block title %}MartinhalApprovalFlow{% endblock %} + + + + + +{% if session.get('user_id') %} +
+ MartinhalApprovalFlow + +
+ {{ session['username'] }} + Sign out +
+
+{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+
© 2026 Martinhal IT - Joao Vaz
+ + diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..7cb6b61 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}Decision — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..1706e20 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}New request — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..a6cb09d --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Sign in — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/mail_status.html b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/mail_status.html new file mode 100644 index 0000000..929da67 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/mail_status.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Mail status — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Mail status

+

Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.

+ +{% if not configured %} +
Mail account not configured — MAIL_USER is empty, so messages are printed to the server console (dev mode) instead of being sent.
+{% endif %} + +
+
{{ stats.get('sent', 0) }}Sent
+
{{ stats.get('error', 0) }}Errors
+
{{ copy_fails }}Sent-copy failures
+
{{ stats.get('dev', 0) }}Dev mode
+
+ +
+ + + {% for m in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
WhenToSubjectRequestDeliverySent folder
{{ m.at }}{{ m.recipients }}{{ m.subject }} + {% if m.detail %}
{{ m.detail }}{% endif %}
{% if m.request_id %}#{{ m.request_id }}{% else %}—{% endif %} + {% if m.status == 'sent' %}sent + {% elif m.status == 'error' %}error + {% else %}dev{% endif %} + + {% if m.sent_copy.startswith('saved') %}{{ m.sent_copy }} + {% elif m.sent_copy.startswith('failed') %}copy failed
{{ m.sent_copy }} + {% else %}{{ m.sent_copy }}{% endif %} +
No mail has been sent yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..1a8e5c2 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if req.attachment %} +

Attachment: + {{ req.attachment.split('_', 1)[-1] }} + (included in every approval mail)

+ {% endif %} + {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'denied' and req.deny_reason %} +

Reason for denial:

+
{{ req.deny_reason }}
+ {% endif %} + {% if req.status == 'pending' %} +
+
+ + Same effect as the current approver's email links. +
+
+
+ + + +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+ + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..921350c --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,66 @@ +{% extends "base.html" %} +{% block title %}Users — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.is_admin %}admin{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..4e12264 --- /dev/null +++ b/OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+ + + +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {created_at} {summary} {deny_reason} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+{% endblock %} diff --git a/OLD_VERSIONS/v8.0 w/approvalflow.zip b/OLD_VERSIONS/v8.0 w/approvalflow.zip new file mode 100644 index 0000000..0dedd1f Binary files /dev/null and b/OLD_VERSIONS/v8.0 w/approvalflow.zip differ diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..6e37c49 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/.env.example @@ -0,0 +1,13 @@ +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Purelymail account (only login and password are needed) --- +# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS. +# If Two Factor Authentication is enabled on the account, use an App Password. +# Leave MAIL_USER empty to print outgoing mail to the console (dev mode). +MAIL_USER=you@yourdomain.com +MAIL_PASSWORD=secret diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..088672f --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/README.md @@ -0,0 +1,94 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it. + +Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password. + +## What it does + +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Attachments.** A request can include one uploaded file (up to 15 MB). It is attached to **every approval mail** sent along the chain, and admins can download it from the request page. Files are stored next to the database in `data/attachments/`. +- **MFA.** Any user can enable two-factor authentication from their Account page (TOTP — Google Authenticator, Authy, 1Password, …). Sign-in then requires password + 6-digit code. Admins can disable MFA for a user who lost their device. +- **Light and dark mode.** A toggle in the top bar (and on the login page) switches themes; the choice is remembered per browser and defaults to the system preference. +- **Images in emails.** Admins upload PNG/JPG/GIF images (up to 2 MB each) in the library shown below the Email wording section of any workflow. Placing an image's `{image:name}` tag in the approval request body, approved notice body, or denied notice body embeds the image at that spot (HTML email with a plain-text fallback). +- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account. + +## Mail server + +The app is preconfigured for Purelymail's servers — you never enter host or port settings: + +| | | +|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS | +| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS | + +Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out. + +Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password. + +## The request lifecycle + +``` +web form (login required) + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{summary}` (creation + each approval with timestamps, for the approved notice), `{deny_reason}`, `{approve_url}`, `{deny_url}`. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit/mail-log helpers +pipeline.py approval chain + decision/notification logic +mailer.py Purelymail SMTP sending, Sent-folder copy, mail log +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..b24ab4e --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/app.py @@ -0,0 +1,539 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, send_file, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +import db +import pipeline +import totp +from mailer import send_mail +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) +# 15 MB attachment limit (+ small allowance for the rest of the form) +app.config["MAX_CONTENT_LENGTH"] = db.MAX_ATTACHMENT_BYTES + 512 * 1024 + + +@app.errorhandler(413) +def too_large(e): + flash("The uploaded file is too large — the limit is 15 MB.", "error") + return redirect(url_for("index")) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +def _start_session(user, next_url=None): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(next_url or url_for("index")) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + if user["mfa_secret"]: + session.clear() + session["mfa_pending"] = user["id"] + session["mfa_next"] = request.args.get("next") or "" + return redirect(url_for("login_mfa")) + return _start_session(user, request.args.get("next")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/login/mfa", methods=["GET", "POST"]) +def login_mfa(): + uid = session.get("mfa_pending") + if not uid: + return redirect(url_for("login")) + user = g.db.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() + if user is None or not user["mfa_secret"]: + session.clear() + return redirect(url_for("login")) + if request.method == "POST": + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + return _start_session(user, session.get("mfa_next") or None) + flash("Wrong verification code — try again.", "error") + return render_template("login.html", mfa_stage=True, username=user["username"]) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- account (MFA) + +@app.route("/account", methods=["GET", "POST"]) +@login_required +def account(): + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + setup_secret = session.get("mfa_setup") + + if request.method == "POST": + act = request.form.get("action") + if act == "begin": + session["mfa_setup"] = totp.new_secret() + return redirect(url_for("account")) + if act == "cancel": + session.pop("mfa_setup", None) + return redirect(url_for("account")) + if act == "confirm" and setup_secret: + if totp.verify(setup_secret, request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret=? WHERE id=?", + (setup_secret, user["id"])) + g.db.commit() + session.pop("mfa_setup", None) + flash("Two-factor authentication is now enabled on your account.", "ok") + else: + flash("That code didn't match — scan the QR code and try again.", "error") + return redirect(url_for("account")) + if act == "disable" and user["mfa_secret"]: + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", (user["id"],)) + g.db.commit() + flash("Two-factor authentication disabled.", "ok") + else: + flash("Wrong code — MFA was not disabled.", "error") + return redirect(url_for("account")) + + otpauth = totp.otpauth_uri(user["username"], setup_secret) if setup_secret else "" + return render_template("account.html", user=user, + setup_secret=setup_secret, otpauth=otpauth) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + requester = request.form.get("request_email", "").strip().lower() + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + upload = request.files.get("attachment") + file_data = upload.read() if upload and upload.filename else b"" + if not requester or "@" not in requester: + flash("Request's e-mail is required and must be a valid address.", "error") + elif not subject or not body: + flash("Subject and message are both required.", "error") + elif len(file_data) > db.MAX_ATTACHMENT_BYTES: + flash("The uploaded file is too large — the limit is 15 MB.", "error") + else: + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + if file_data: + stored = f"{rid}_{secure_filename(upload.filename) or 'attachment'}" + os.makedirs(db.ATTACH_DIR, exist_ok=True) + with open(db.attachment_path(stored), "wb") as fh: + fh.write(file_data) + g.db.execute("UPDATE requests SET attachment=? WHERE id=?", (stored, rid)) + db.log(g.db, rid, "attachment saved", + f"'{upload.filename}' ({len(file_data)} bytes) — included in every approval mail") + g.db.commit() + pipeline.send_approval_request(rid) + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15", + (session["username"], (user["email"] or "-").lower())).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests, + user_email=user["email"] or "") + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain, + error="A reason for denial is required."), 400 + status, req2, message = pipeline.decide(token, action, decided_by, reason=reason) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + flash("A reason for denial is required.", "error") + return redirect(url_for("admin_request", rid=rid)) + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason) + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain) + + +@app.route("/admin/request//attachment") +@admin_required +def request_attachment(rid): + req = g.db.execute("SELECT attachment FROM requests WHERE id=?", (rid,)).fetchone() + if req is None or not req["attachment"]: + abort(404) + path = db.attachment_path(req["attachment"]) + if not os.path.exists(path): + abort(404) + return send_file(path, as_attachment=True, + download_name=req["attachment"].split("_", 1)[-1]) + + +@app.route("/admin/request//send-log", methods=["POST"]) +@admin_required +def send_request_log(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + to = request.form.get("email", "").strip() + if not to or "@" not in to: + flash("A valid e-mail address is required to send the log.", "error") + return redirect(url_for("admin_request", rid=rid)) + + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + chain = pipeline.approver_chain(req) + participants = json.loads(req["participants"]) + + lines = [ + f"Full log of request #{req['id']} — {req['subject']}", + "=" * 60, + f"Workflow: {req['wf_name']}", + f"Requester: {req['requester']}", + f"Created: {req['created_at']}", + f"Status: {req['status'].upper()}", + ] + if req["decided_by"]: + lines.append(f"Final decision: by {req['decided_by']} at {req['decided_at']}") + if req["deny_reason"]: + lines.append(f"Denial reason: {req['deny_reason']}") + lines += [ + f"Approval chain: {' -> '.join(chain)}", + f"People involved: {', '.join(participants)}", + "", + "Message:", + req["body"], + "", + "Event log:", + "-" * 60, + ] + for l in logs: + lines.append(f"{l['at']} | {l['event']}") + if l["detail"]: + lines.append(f"{'':25} {l['detail']}") + body = "\n".join(lines) + + ok, detail = send_mail(to, f"[{req['wf_name']}] Full log of request #{req['id']}: {req['subject']}", + body, request_id=rid) + db.log(g.db, rid, "log sent" if ok else "mail error", + f"Full log emailed to {to} by {session['username']} — {detail}") + g.db.commit() + flash(f"Full log sent to {to}." if ok else f"Could not send the log: {detail}", + "ok" if ok else "error") + return redirect(url_for("admin_request", rid=rid)) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES, + images=_email_images()) + + +# ---------------------------------------------------------------- admin: mail status + +@app.route("/admin/mail") +@admin_required +def mail_status(): + rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall() + stats = {s["status"]: s["c"] for s in + g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")} + copy_fails = g.db.execute( + "SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"] + configured = bool(os.environ.get("MAIL_USER")) + return render_template("mail_status.html", rows=rows, stats=stats, + copy_fails=copy_fails, configured=configured) + + +# ---------------------------------------------------------------- admin: email images + +def _email_images(): + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + return sorted(f for f in os.listdir(db.EMAIL_IMG_DIR) + if os.path.splitext(f)[1].lower() in db.EMAIL_IMAGE_TYPES) + + +@app.route("/admin/email-images/upload", methods=["POST"]) +@admin_required +def email_image_upload(): + nxt = request.form.get("next") or url_for("admin") + upload = request.files.get("image") + if not upload or not upload.filename: + flash("Choose an image file to upload.", "error") + return redirect(nxt) + name = secure_filename(upload.filename) + ext = os.path.splitext(name)[1].lower() + if ext not in db.EMAIL_IMAGE_TYPES: + flash("Only PNG, JPG and GIF images can be used in emails.", "error") + return redirect(nxt) + data = upload.read() + if len(data) > db.MAX_EMAIL_IMAGE_BYTES: + flash("Email images are limited to 2 MB each.", "error") + return redirect(nxt) + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + if os.path.exists(os.path.join(db.EMAIL_IMG_DIR, name)): + flash(f"An image named “{name}” already exists — delete it first or rename the file.", "error") + return redirect(nxt) + with open(os.path.join(db.EMAIL_IMG_DIR, name), "wb") as fh: + fh.write(data) + flash(f"Image “{name}” uploaded — use it with {{image:{name}}} in the email bodies.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/delete", methods=["POST"]) +@admin_required +def email_image_delete(): + nxt = request.form.get("next") or url_for("admin") + name = secure_filename(request.form.get("name", "")) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if name and os.path.isfile(path): + os.remove(path) + flash(f"Image “{name}” deleted.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/") +@admin_required +def email_image(name): + name = secure_filename(name) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if not (name and os.path.isfile(path)): + abort(404) + return send_file(path) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "mfa_off": + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", + (f.get("user_id", type=int),)) + flash("MFA disabled for that user — they can re-enable it from their Account page.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..7d5ca9e --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/db.py @@ -0,0 +1,227 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) +ATTACH_DIR = os.path.join(os.path.dirname(DB_PATH), "attachments") +EMAIL_IMG_DIR = os.path.join(os.path.dirname(DB_PATH), "email_images") + +MAX_EMAIL_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB per email image +EMAIL_IMAGE_TYPES = {".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg", ".gif": "gif"} + +MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024 # 15 MB + + +def attachment_path(stored_name): + return os.path.join(ATTACH_DIR, stored_name) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + mfa_secret TEXT NOT NULL DEFAULT '', -- empty = MFA disabled + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + deny_reason TEXT NOT NULL DEFAULT '', + attachment TEXT NOT NULL DEFAULT '', -- stored filename of the uploaded file, if any + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL REFERENCES requests(id) ON DELETE CASCADE, + step INTEGER NOT NULL, -- 1-based position in the chain + decided_by TEXT NOT NULL, + decision TEXT NOT NULL, -- approved / denied + at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS mail_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + request_id INTEGER, -- related request, if any + recipients TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, -- sent / error / dev + detail TEXT DEFAULT '', -- SMTP error message, if any + sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Summary:\n{summary}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Reason for denial:\n{deny_reason}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH, timeout=15) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA busy_timeout = 15000") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + if rcols and "deny_reason" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN deny_reason TEXT NOT NULL DEFAULT ''") + print("[migrate] Added deny_reason to requests.") + if rcols and "attachment" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN attachment TEXT NOT NULL DEFAULT ''") + print("[migrate] Added attachment to requests.") + ucols = {r["name"] for r in con.execute("PRAGMA table_info(users)")} + if ucols and "mfa_secret" not in ucols: + con.execute("ALTER TABLE users ADD COLUMN mfa_secret TEXT NOT NULL DEFAULT ''") + print("[migrate] Added mfa_secret to users.") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..51c7fc9 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/mailer.py @@ -0,0 +1,145 @@ +"""Outbound email via Purelymail, and template rendering. + +Preconfigured for Purelymail — only the account's login and password are needed: + + MAIL_USER=you@yourdomain.com (or you@purelymail.com) + MAIL_PASSWORD=... (use an App Password if 2FA is enabled) + +Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a +copy is saved to the account's Sent folder over IMAP +(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is +recorded in the mail_log table shown on the admin "Mail status" page. +""" +import html as html_mod +import imaplib +import os +import smtplib +import time +from email.message import EmailMessage +from email.utils import make_msgid + +import db + +SMTP_HOST = "smtp.purelymail.com" +SMTP_PORT = 465 # SSL/TLS +IMAP_HOST = "imap.purelymail.com" +IMAP_PORT = 993 # SSL/TLS + +SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items") + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def _save_to_sent(user, password, msg) -> str: + """Append a copy of the message to the account's Sent folder. Returns a status string.""" + try: + with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap: + imap.login(user, password) + raw = msg.as_bytes() + stamp = imaplib.Time2Internaldate(time.time()) + last_err = "" + for folder in SENT_FOLDER_CANDIDATES: + status, resp = imap.append(folder, r"(\Seen)", stamp, raw) + if status == "OK": + return f"saved to '{folder}'" + last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else "" + # No known folder worked — create "Sent" and try once more. + imap.create("Sent") + status, resp = imap.append("Sent", r"(\Seen)", stamp, raw) + if status == "OK": + return "saved to 'Sent' (folder created)" + return f"failed: {last_err or 'could not append to any Sent folder'}" + except Exception as e: + return f"failed: {e}" + + +def _record(request_id, recipients, subject, status, detail, sent_copy): + try: + con = db.get_db() + con.execute( + "INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)" + " VALUES (?,?,?,?,?,?,?)", + (db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy)) + con.commit() + con.close() + except Exception as e: + print(f"[mail] Could not record mail log entry: {e}") + + +def send_mail(to_addrs, subject, body, request_id=None, attachments=None, inline_images=None): + """Send an email through Purelymail, keep a copy in the Sent folder, and + record the attempt in the mail log. Returns (ok, detail). + + attachments: optional list of (filename, bytes, maintype, subtype). + inline_images: optional dict {marker_name: (bytes, subtype)}. When given, + an HTML alternative is built where each "{image:marker_name}" + in the body becomes an embedded ; the plain-text part + shows "[image: marker_name]" instead. + """ + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + user = os.environ.get("MAIL_USER", "") + password = os.environ.get("MAIL_PASSWORD", "") + + msg = EmailMessage() + msg["From"] = user or "approvalflow@localhost" + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + + if inline_images: + text = body + for name in inline_images: + text = text.replace("{image:" + name + "}", f"[image: {name}]") + msg.set_content(text) + + html = html_mod.escape(body).replace("\n", "
\n") + cids = {} + for name in inline_images: + cid = make_msgid(domain="martinhal.approvalflow") + cids[name] = cid + html = html.replace("{image:" + name + "}", + f'{html_mod.escape(name)}') + msg.add_alternative( + f'
{html}
', + subtype="html") + html_part = msg.get_payload()[-1] + for name, (data, subtype) in inline_images.items(): + html_part.add_related(data, maintype="image", subtype=subtype, cid=cids[name]) + else: + msg.set_content(body) + + for filename, data, maintype, subtype in (attachments or []): + msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) + + if not user: + # Dev mode: print instead of sending so the app is testable without an account. + att = "".join(f"\n[attachment: {f} ({len(b)} bytes)]" for f, b, *_ in (attachments or [])) + img = "".join(f"\n[inline image: {n} ({len(d)} bytes, {s})]" for n, (d, s) in (inline_images or {}).items()) + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}{att}{img}\n{'-'*50}") + _record(request_id, to_addrs, subject, "dev", + "MAIL_USER not set — printed to console", "skipped (dev mode)") + return True, "dev mode (MAIL_USER not set) — printed to console" + + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(user, password) + server.send_message(msg) + except Exception as e: + detail = f"SMTP error: {e}" + _record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)") + return False, detail + + sent_copy = _save_to_sent(user, password, msg) + _record(request_id, to_addrs, subject, "sent", "", sent_copy) + return True, f"sent to {', '.join(to_addrs)}" diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..732a911 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,236 @@ +"""Request pipeline for requests created on the site. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import mimetypes +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "created_at": req["created_at"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def _record_decision(con, request_id, step, decided_by, decision): + con.execute("INSERT INTO decisions (request_id, step, decided_by, decision, at) VALUES (?,?,?,?,?)", + (request_id, step, decided_by, decision, db.now())) + + +def build_summary(con, req, total_steps): + """Human-readable history: when the request was created and who + approved (or denied) each step, with timestamps.""" + lines = [f" Created {req['created_at']} by {req['requester']}"] + for d in con.execute("SELECT * FROM decisions WHERE request_id=? ORDER BY id", (req["id"],)): + verb = "Approved" if d["decision"] == "approved" else "DENIED" + lines.append(f" Step {d['step']}/{total_steps} {verb} {d['at']} by {d['decided_by']}") + return "\n".join(lines) + + +def collect_inline_images(rendered_body): + """Find {image:name} markers in a rendered body and load those images + from the email image library. Returns a dict for send_mail, or None.""" + import re + names = set(re.findall(r"\{image:([A-Za-z0-9._\-]+)\}", rendered_body)) + images = {} + for name in names: + ext = os.path.splitext(name)[1].lower() + subtype = db.EMAIL_IMAGE_TYPES.get(ext) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if subtype and os.path.isfile(path): + with open(path, "rb") as f: + images[name] = (f.read(), subtype) + return images or None + + +def load_attachment(req): + """Load the request's uploaded file (if any) as a send_mail attachment list.""" + if not req["attachment"]: + return None + path = db.attachment_path(req["attachment"]) + try: + with open(path, "rb") as f: + data = f.read() + except OSError: + return None + # the stored name is "_"; show the original name in the mail + original = req["attachment"].split("_", 1)[-1] + ctype = mimetypes.guess_type(original)[0] or "application/octet-stream" + maintype, subtype = ctype.split("/", 1) + return [(original, data, maintype, subtype)] + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + con.commit() # release any pending writes before slow mail I/O + v = _vars(wf, req) + body_t = wf["approval_body"] + if "{created_at}" not in body_t: # creation time is mandatory in every mail + body_t = "Request created: {created_at}\n\n" + body_t + attachments = load_attachment(req) + rendered_body = render(body_t, **v) + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + rendered_body, + request_id=request_id, + attachments=attachments, + inline_images=collect_inline_images(rendered_body)) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + summary = build_summary(con, req, len(approver_chain(wf))) + con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper(), + summary=summary, deny_reason=req["deny_reason"]) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + # These are mandatory in the outcome notice: append them if the workflow's + # custom template doesn't include the placeholder. + if new_status == "approved" and "{summary}" not in body_t: + body_t += "\n\nSummary:\n{summary}\n" + if new_status == "denied" and "{deny_reason}" not in body_t: + body_t += "\n\nReason for denial:\n{deny_reason}\n" + if "{created_at}" not in body_t and "{summary}" not in body_t: + # creation time is mandatory in every mail ({summary} already includes it) + body_t = "Request created: {created_at}\n\n" + body_t + rendered_body = render(body_t, **v) + ok, detail = send_mail(participants, render(subj_t, **v), rendered_body, + request_id=req["id"], + inline_images=collect_inline_images(rendered_body)) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by, reason=""): + """Handle a decision at the request's current step. + + A denial requires a reason (callers enforce it as a mandatory field; + this is a second line of defense). + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + reason = (reason or "").strip() + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + if not reason: + return "needreason", req, "A reason for denial is required." + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=?, deny_reason=? WHERE id=?", + (decided_by, db.now(), reason, req["id"])) + _record_decision(con, req["id"], step + 1, decided_by, "denied") + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped. Reason: {reason}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + _record_decision(con, req["id"], step + 1, decided_by, "approved") + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + con.commit() + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..73489e0 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/static/style.css @@ -0,0 +1,195 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root, [data-theme="light"] { + --paper: #fafbfc; + --card: #ffffff; + --field: #ffffff; + --hover: #f2f6f8; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --flash-ok-bg: #e4f3ee; --flash-ok-line: #bfe0d5; --flash-ok-ink: #0b5c4c; + --flash-err-bg: #f9e8e6; --flash-err-line: #edc7c3; --flash-err-ink: #872a24; + --radius: 8px; + font-size: 16px; +} +[data-theme="dark"] { + --paper: #10161c; + --card: #18212a; + --field: #10161c; + --hover: #1e2a35; + --ink: #e4ebf1; + --ink-soft: #93a5b2; + --line: #2b3945; + --approve: #35b598; + --deny: #e07067; + --pending: #d9a24a; + --focus: #6aa9e0; + --flash-ok-bg: #12352c; --flash-ok-line: #1d5a4a; --flash-ok-ink: #8fd8c3; + --flash-err-bg: #3c1f1c; --flash-err-line: #6b3530; --flash-err-ink: #f0b1aa; +} +[data-theme="dark"] body { color-scheme: dark; } +[data-theme="dark"] button, [data-theme="dark"] .btn { + border-color: var(--line); background: #26323e; +} +[data-theme="dark"] button:hover, [data-theme="dark"] .btn:hover { background: #31404e; } +[data-theme="dark"] .btn.approve { background: #14523f; border-color: #14523f; } +[data-theme="dark"] .btn.deny { background: #79312a; border-color: #79312a; } +[data-theme="dark"] .btn.ghost { background: transparent; color: var(--ink); } +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea, input[type=file] { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: var(--field); +} +input[type=file] { padding: .4rem .5rem; } +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: var(--hover); } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: var(--flash-ok-bg); border: 1px solid var(--flash-ok-line); color: var(--flash-ok-ink); } +.flash.error { background: var(--flash-err-bg); border: 1px solid var(--flash-err-line); color: var(--flash-err-ink); } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: var(--hover); border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} + +/* footer */ +.footer { + max-width: 980px; margin: 0 auto; padding: 1.2rem 1.25rem 2rem; + font-size: .8rem; color: var(--ink-soft); text-align: center; + border-top: 1px solid var(--line); +} + +/* theme toggle */ +.theme-toggle { + margin: 0; padding: .15rem .55rem; font-size: 1rem; line-height: 1; + background: transparent; border: 1px solid #4a5b68; color: #b9c6cf; + border-radius: 999px; cursor: pointer; +} +.theme-toggle:hover { color: #fff; border-color: #7fd6c2; background: transparent; } +.theme-toggle.floating { + position: fixed; top: 1rem; right: 1rem; z-index: 10; + border-color: var(--line); color: var(--ink-soft); +} +.theme-toggle.floating:hover { color: var(--ink); border-color: var(--focus); } + +/* email image library */ +.imggrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; } +.imgcard { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--field); } +.imgcard img { display: block; width: 100%; height: 110px; object-fit: contain; background: #fff; } +.imgmeta { padding: .5rem .6rem; display: flex; flex-direction: column; gap: .35rem; font-size: .78rem; } +.imgmeta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.imgmeta .tag { + background: var(--hover); border: 1px dashed var(--line); border-radius: 5px; + padding: .15rem .4rem; cursor: copy; word-break: break-all; white-space: normal; +} diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/account.html b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/account.html new file mode 100644 index 0000000..aa1fbfe --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/account.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} +{% block title %}Account — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Account

+

Signed in as {{ user.username }}{% if user.email %} · {{ user.email }}{% endif %}

+ +
+

Two-factor authentication (MFA)

+ + {% if user.mfa_secret %} +

enabled

+

Signing in requires your password and a 6-digit code from your authenticator app.

+
+ + + + +
+ + {% elif setup_secret %} +

1 — Scan this QR code with Google Authenticator, Authy, 1Password, or any TOTP app. + If you can't scan, enter the secret manually.

+
+

Secret: {{ setup_secret }}

+

2 — Enter the 6-digit code the app shows to confirm.

+
+ + + +
+
+ + +
+ + + + {% else %} +

disabled

+

Add a second sign-in step: your password plus a 6-digit code from an authenticator app on your phone.

+
+ + +
+ {% endif %} +
+{% endblock %} diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..98913ab --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + {% else %} + + {% endfor %} +
NameApproval chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..ed990e5 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,52 @@ + + + + + +{% block title %}MartinhalApprovalFlow{% endblock %} + + + + + + +{% if session.get('user_id') %} +
+ MartinhalApprovalFlow + +
+ + Account + {{ session['username'] }} + Sign out +
+
+{% else %} + +{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+
© 2026 Martinhal IT - Joao Vaz
+ + diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..7cb6b61 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}Decision — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..1706e20 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}New request — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..6254420 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Sign in — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/mail_status.html b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/mail_status.html new file mode 100644 index 0000000..929da67 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/mail_status.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Mail status — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Mail status

+

Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.

+ +{% if not configured %} +
Mail account not configured — MAIL_USER is empty, so messages are printed to the server console (dev mode) instead of being sent.
+{% endif %} + +
+
{{ stats.get('sent', 0) }}Sent
+
{{ stats.get('error', 0) }}Errors
+
{{ copy_fails }}Sent-copy failures
+
{{ stats.get('dev', 0) }}Dev mode
+
+ +
+ + + {% for m in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
WhenToSubjectRequestDeliverySent folder
{{ m.at }}{{ m.recipients }}{{ m.subject }} + {% if m.detail %}
{{ m.detail }}{% endif %}
{% if m.request_id %}#{{ m.request_id }}{% else %}—{% endif %} + {% if m.status == 'sent' %}sent + {% elif m.status == 'error' %}error + {% else %}dev{% endif %} + + {% if m.sent_copy.startswith('saved') %}{{ m.sent_copy }} + {% elif m.sent_copy.startswith('failed') %}copy failed
{{ m.sent_copy }} + {% else %}{{ m.sent_copy }}{% endif %} +
No mail has been sent yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..1a8e5c2 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if req.attachment %} +

Attachment: + {{ req.attachment.split('_', 1)[-1] }} + (included in every approval mail)

+ {% endif %} + {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'denied' and req.deny_reason %} +

Reason for denial:

+
{{ req.deny_reason }}
+ {% endif %} + {% if req.status == 'pending' %} +
+
+ + Same effect as the current approver's email links. +
+
+
+ + + +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+ + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..223e422 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}Users — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow. Users enable MFA themselves on their Account page; you can disable it here if someone loses their device.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.is_admin %}admin{% endif %} + {% if u.mfa_secret %}MFA on{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.mfa_secret %} +
+ + + +
+ {% endif %} + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..7678fc1 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+ + + +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {created_at} {summary} {deny_reason} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+ +
+

Images for email bodies

+

These images can be used in the approval request body, approved notice body and + denied notice body above. Copy an image's tag into the text where the image should appear — + it is embedded in the email at that spot. PNG, JPG or GIF, up to 2 MB each. The library is shared by all workflows.

+ + {% if images %} +
+ {% for name in images %} +
+ {{ name }} +
+ {{ name }} + {{ '{image:' + name + '}' }} +
+ + + +
+
+
+ {% endfor %} +
+ {% else %} +

No images uploaded yet.

+ {% endif %} + +
+ + + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/totp.py b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/totp.py new file mode 100644 index 0000000..909a241 --- /dev/null +++ b/OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/totp.py @@ -0,0 +1,40 @@ +"""Time-based one-time passwords (TOTP, RFC 6238) using only the standard +library. Compatible with Google Authenticator, Authy, 1Password, etc.""" +import base64 +import hashlib +import hmac +import secrets +import struct +import time +from urllib.parse import quote + +ISSUER = "MartinhalApprovalFlow" + + +def new_secret() -> str: + """A new base32 secret to enroll in an authenticator app.""" + return base64.b32encode(secrets.token_bytes(20)).decode() + + +def _code_at(secret: str, counter: int) -> str: + key = base64.b32decode(secret + "=" * (-len(secret) % 8), casefold=True) + digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 15 + number = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000 + return f"{number:06d}" + + +def verify(secret: str, code: str) -> bool: + """Check a 6-digit code, allowing one 30s step of clock drift each way.""" + code = (code or "").strip().replace(" ", "") + if not (secret and code.isdigit() and len(code) == 6): + return False + counter = int(time.time() // 30) + return any(hmac.compare_digest(_code_at(secret, counter + drift), code) + for drift in (-1, 0, 1)) + + +def otpauth_uri(username: str, secret: str) -> str: + """URI encoded as a QR code for authenticator apps.""" + return (f"otpauth://totp/{quote(ISSUER)}:{quote(username)}" + f"?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period=30") diff --git a/OLD_VERSIONS/v9.0 final/approvalflow.zip b/OLD_VERSIONS/v9.0 final/approvalflow.zip new file mode 100644 index 0000000..b69f098 Binary files /dev/null and b/OLD_VERSIONS/v9.0 final/approvalflow.zip differ diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..51b61a3 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/.env.example @@ -0,0 +1,18 @@ +# NOTE: Docker Compose treats '$' in this file as variable substitution. +# If any value (e.g. a password) contains a literal '$', write it as '$$': +# MAIL_PASSWORD=xK9$$vh4pQ2 -> the container receives xK9$vh4pQ2 +# Otherwise you'll see a warning like: The "..." variable is not set. + +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Purelymail account (only login and password are needed) --- +# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS. +# If Two Factor Authentication is enabled on the account, use an App Password. +# Leave MAIL_USER empty to print outgoing mail to the console (dev mode). +MAIL_USER=you@yourdomain.com +MAIL_PASSWORD=secret diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..08601c1 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/README.md @@ -0,0 +1,97 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it. + +Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password. + +## What it does + +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Attachments.** A request can include one uploaded file (up to 15 MB). It is attached to **every approval mail** sent along the chain, and admins can download it from the request page. Files are stored next to the database in `data/attachments/`. +- **Configure profile.** Every user has a profile page (top-right corner): first name, last name, unit location, e-mail, change password, and MFA. The profile e-mail pre-fills the mandatory "Request's e-mail" field when submitting a request. +- **MFA.** Any user can enable two-factor authentication from their Configure profile page (TOTP — Google Authenticator, Authy, 1Password, …). Sign-in then requires password + 6-digit code. Admins can disable MFA for a user who lost their device. +- **Light and dark mode.** A toggle in the top bar (and on the login page) switches themes; the choice is remembered per browser and defaults to the system preference. +- **Images in emails.** Admins upload PNG/JPG/GIF images (up to 2 MB each) in the library shown below the Email wording section of any workflow. Placing an image's `{image:name}` tag in the approval request body, approved notice body, or denied notice body embeds the image at that spot (HTML email with a plain-text fallback). +- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +> **If a value in `.env` contains a `$`** (common in passwords), escape it as `$$` — e.g. `MAIL_PASSWORD=xK9$$vh4pQ2`. Docker Compose otherwise treats `$word` as a variable and silently replaces it with a blank string, printing a warning like `The "word" variable is not set`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account. + +## Mail server + +The app is preconfigured for Purelymail's servers — you never enter host or port settings: + +| | | +|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS | +| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS | + +Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out. + +Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password. + +## The request lifecycle + +``` +web form (login required) + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{summary}` (creation + each approval with timestamps, for the approved notice), `{deny_reason}`, `{approve_url}`, `{deny_url}`. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit/mail-log helpers +pipeline.py approval chain + decision/notification logic +mailer.py Purelymail SMTP sending, Sent-folder copy, mail log +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..3021fe4 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/app.py @@ -0,0 +1,566 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, send_file, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +import db +import pipeline +import totp +from mailer import send_mail +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) +# 15 MB attachment limit (+ small allowance for the rest of the form) +app.config["MAX_CONTENT_LENGTH"] = db.MAX_ATTACHMENT_BYTES + 512 * 1024 + + +@app.errorhandler(413) +def too_large(e): + flash("The uploaded file is too large — the limit is 15 MB.", "error") + return redirect(url_for("index")) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +def _start_session(user, next_url=None): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(next_url or url_for("index")) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + if user["mfa_secret"]: + session.clear() + session["mfa_pending"] = user["id"] + session["mfa_next"] = request.args.get("next") or "" + return redirect(url_for("login_mfa")) + return _start_session(user, request.args.get("next")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/login/mfa", methods=["GET", "POST"]) +def login_mfa(): + uid = session.get("mfa_pending") + if not uid: + return redirect(url_for("login")) + user = g.db.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() + if user is None or not user["mfa_secret"]: + session.clear() + return redirect(url_for("login")) + if request.method == "POST": + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + return _start_session(user, session.get("mfa_next") or None) + flash("Wrong verification code — try again.", "error") + return render_template("login.html", mfa_stage=True, username=user["username"]) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- account (MFA) + +@app.route("/account", methods=["GET", "POST"]) +@login_required +def account(): + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + setup_secret = session.get("mfa_setup") + + if request.method == "POST": + act = request.form.get("action") + if act == "profile": + email = request.form.get("email", "").strip().lower() + if email and "@" not in email: + flash("That e-mail address doesn't look valid.", "error") + else: + g.db.execute( + "UPDATE users SET first_name=?, last_name=?, unit_location=?, email=? WHERE id=?", + (request.form.get("first_name", "").strip(), + request.form.get("last_name", "").strip(), + request.form.get("unit_location", "").strip(), + email, user["id"])) + g.db.commit() + flash("Profile saved.", "ok") + return redirect(url_for("account")) + if act == "password": + if not check_password_hash(user["password_hash"], request.form.get("current_password", "")): + flash("Your current password is wrong.", "error") + elif not request.form.get("new_password"): + flash("The new password can't be empty.", "error") + elif request.form.get("new_password") != request.form.get("confirm_password"): + flash("The new passwords don't match.", "error") + else: + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(request.form["new_password"]), user["id"])) + g.db.commit() + flash("Password changed.", "ok") + return redirect(url_for("account")) + if act == "begin": + session["mfa_setup"] = totp.new_secret() + return redirect(url_for("account")) + if act == "cancel": + session.pop("mfa_setup", None) + return redirect(url_for("account")) + if act == "confirm" and setup_secret: + if totp.verify(setup_secret, request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret=? WHERE id=?", + (setup_secret, user["id"])) + g.db.commit() + session.pop("mfa_setup", None) + flash("Two-factor authentication is now enabled on your account.", "ok") + else: + flash("That code didn't match — scan the QR code and try again.", "error") + return redirect(url_for("account")) + if act == "disable" and user["mfa_secret"]: + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", (user["id"],)) + g.db.commit() + flash("Two-factor authentication disabled.", "ok") + else: + flash("Wrong code — MFA was not disabled.", "error") + return redirect(url_for("account")) + + otpauth = totp.otpauth_uri(user["username"], setup_secret) if setup_secret else "" + return render_template("account.html", user=user, + setup_secret=setup_secret, otpauth=otpauth) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + requester = request.form.get("request_email", "").strip().lower() + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + upload = request.files.get("attachment") + file_data = upload.read() if upload and upload.filename else b"" + if not requester or "@" not in requester: + flash("Request's e-mail is required and must be a valid address.", "error") + elif not subject or not body: + flash("Subject and message are both required.", "error") + elif len(file_data) > db.MAX_ATTACHMENT_BYTES: + flash("The uploaded file is too large — the limit is 15 MB.", "error") + else: + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + if file_data: + stored = f"{rid}_{secure_filename(upload.filename) or 'attachment'}" + os.makedirs(db.ATTACH_DIR, exist_ok=True) + with open(db.attachment_path(stored), "wb") as fh: + fh.write(file_data) + g.db.execute("UPDATE requests SET attachment=? WHERE id=?", (stored, rid)) + db.log(g.db, rid, "attachment saved", + f"'{upload.filename}' ({len(file_data)} bytes) — included in every approval mail") + g.db.commit() + pipeline.send_approval_request(rid) + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15", + (session["username"], (user["email"] or "-").lower())).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests, + user_email=user["email"] or "") + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain, + error="A reason for denial is required."), 400 + status, req2, message = pipeline.decide(token, action, decided_by, reason=reason) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + flash("A reason for denial is required.", "error") + return redirect(url_for("admin_request", rid=rid)) + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason) + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain) + + +@app.route("/admin/request//attachment") +@admin_required +def request_attachment(rid): + req = g.db.execute("SELECT attachment FROM requests WHERE id=?", (rid,)).fetchone() + if req is None or not req["attachment"]: + abort(404) + path = db.attachment_path(req["attachment"]) + if not os.path.exists(path): + abort(404) + return send_file(path, as_attachment=True, + download_name=req["attachment"].split("_", 1)[-1]) + + +@app.route("/admin/request//send-log", methods=["POST"]) +@admin_required +def send_request_log(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + to = request.form.get("email", "").strip() + if not to or "@" not in to: + flash("A valid e-mail address is required to send the log.", "error") + return redirect(url_for("admin_request", rid=rid)) + + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + chain = pipeline.approver_chain(req) + participants = json.loads(req["participants"]) + + lines = [ + f"Full log of request #{req['id']} — {req['subject']}", + "=" * 60, + f"Workflow: {req['wf_name']}", + f"Requester: {req['requester']}", + f"Created: {req['created_at']}", + f"Status: {req['status'].upper()}", + ] + if req["decided_by"]: + lines.append(f"Final decision: by {req['decided_by']} at {req['decided_at']}") + if req["deny_reason"]: + lines.append(f"Denial reason: {req['deny_reason']}") + lines += [ + f"Approval chain: {' -> '.join(chain)}", + f"People involved: {', '.join(participants)}", + "", + "Message:", + req["body"], + "", + "Event log:", + "-" * 60, + ] + for l in logs: + lines.append(f"{l['at']} | {l['event']}") + if l["detail"]: + lines.append(f"{'':25} {l['detail']}") + body = "\n".join(lines) + + ok, detail = send_mail(to, f"[{req['wf_name']}] Full log of request #{req['id']}: {req['subject']}", + body, request_id=rid) + db.log(g.db, rid, "log sent" if ok else "mail error", + f"Full log emailed to {to} by {session['username']} — {detail}") + g.db.commit() + flash(f"Full log sent to {to}." if ok else f"Could not send the log: {detail}", + "ok" if ok else "error") + return redirect(url_for("admin_request", rid=rid)) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES, + images=_email_images()) + + +# ---------------------------------------------------------------- admin: mail status + +@app.route("/admin/mail") +@admin_required +def mail_status(): + rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall() + stats = {s["status"]: s["c"] for s in + g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")} + copy_fails = g.db.execute( + "SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"] + configured = bool(os.environ.get("MAIL_USER")) + return render_template("mail_status.html", rows=rows, stats=stats, + copy_fails=copy_fails, configured=configured) + + +# ---------------------------------------------------------------- admin: email images + +def _email_images(): + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + return sorted(f for f in os.listdir(db.EMAIL_IMG_DIR) + if os.path.splitext(f)[1].lower() in db.EMAIL_IMAGE_TYPES) + + +@app.route("/admin/email-images/upload", methods=["POST"]) +@admin_required +def email_image_upload(): + nxt = request.form.get("next") or url_for("admin") + upload = request.files.get("image") + if not upload or not upload.filename: + flash("Choose an image file to upload.", "error") + return redirect(nxt) + name = secure_filename(upload.filename) + ext = os.path.splitext(name)[1].lower() + if ext not in db.EMAIL_IMAGE_TYPES: + flash("Only PNG, JPG and GIF images can be used in emails.", "error") + return redirect(nxt) + data = upload.read() + if len(data) > db.MAX_EMAIL_IMAGE_BYTES: + flash("Email images are limited to 2 MB each.", "error") + return redirect(nxt) + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + if os.path.exists(os.path.join(db.EMAIL_IMG_DIR, name)): + flash(f"An image named “{name}” already exists — delete it first or rename the file.", "error") + return redirect(nxt) + with open(os.path.join(db.EMAIL_IMG_DIR, name), "wb") as fh: + fh.write(data) + flash(f"Image “{name}” uploaded — use it with {{image:{name}}} in the email bodies.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/delete", methods=["POST"]) +@admin_required +def email_image_delete(): + nxt = request.form.get("next") or url_for("admin") + name = secure_filename(request.form.get("name", "")) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if name and os.path.isfile(path): + os.remove(path) + flash(f"Image “{name}” deleted.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/") +@admin_required +def email_image(name): + name = secure_filename(name) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if not (name and os.path.isfile(path)): + abort(404) + return send_file(path) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "mfa_off": + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", + (f.get("user_id", type=int),)) + flash("MFA disabled for that user — they can re-enable it from their Account page.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..f2dd27c --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/db.py @@ -0,0 +1,234 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) +ATTACH_DIR = os.path.join(os.path.dirname(DB_PATH), "attachments") +EMAIL_IMG_DIR = os.path.join(os.path.dirname(DB_PATH), "email_images") + +MAX_EMAIL_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB per email image +EMAIL_IMAGE_TYPES = {".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg", ".gif": "gif"} + +MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024 # 15 MB + + +def attachment_path(stored_name): + return os.path.join(ATTACH_DIR, stored_name) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + first_name TEXT NOT NULL DEFAULT '', + last_name TEXT NOT NULL DEFAULT '', + unit_location TEXT NOT NULL DEFAULT '', + mfa_secret TEXT NOT NULL DEFAULT '', -- empty = MFA disabled + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + deny_reason TEXT NOT NULL DEFAULT '', + attachment TEXT NOT NULL DEFAULT '', -- stored filename of the uploaded file, if any + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL REFERENCES requests(id) ON DELETE CASCADE, + step INTEGER NOT NULL, -- 1-based position in the chain + decided_by TEXT NOT NULL, + decision TEXT NOT NULL, -- approved / denied + at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS mail_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + request_id INTEGER, -- related request, if any + recipients TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, -- sent / error / dev + detail TEXT DEFAULT '', -- SMTP error message, if any + sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Summary:\n{summary}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Reason for denial:\n{deny_reason}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH, timeout=15) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA busy_timeout = 15000") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + if rcols and "deny_reason" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN deny_reason TEXT NOT NULL DEFAULT ''") + print("[migrate] Added deny_reason to requests.") + if rcols and "attachment" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN attachment TEXT NOT NULL DEFAULT ''") + print("[migrate] Added attachment to requests.") + ucols = {r["name"] for r in con.execute("PRAGMA table_info(users)")} + if ucols and "mfa_secret" not in ucols: + con.execute("ALTER TABLE users ADD COLUMN mfa_secret TEXT NOT NULL DEFAULT ''") + print("[migrate] Added mfa_secret to users.") + for col in ("first_name", "last_name", "unit_location"): + if ucols and col not in ucols: + con.execute(f"ALTER TABLE users ADD COLUMN {col} TEXT NOT NULL DEFAULT ''") + print(f"[migrate] Added {col} to users.") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..51c7fc9 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/mailer.py @@ -0,0 +1,145 @@ +"""Outbound email via Purelymail, and template rendering. + +Preconfigured for Purelymail — only the account's login and password are needed: + + MAIL_USER=you@yourdomain.com (or you@purelymail.com) + MAIL_PASSWORD=... (use an App Password if 2FA is enabled) + +Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a +copy is saved to the account's Sent folder over IMAP +(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is +recorded in the mail_log table shown on the admin "Mail status" page. +""" +import html as html_mod +import imaplib +import os +import smtplib +import time +from email.message import EmailMessage +from email.utils import make_msgid + +import db + +SMTP_HOST = "smtp.purelymail.com" +SMTP_PORT = 465 # SSL/TLS +IMAP_HOST = "imap.purelymail.com" +IMAP_PORT = 993 # SSL/TLS + +SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items") + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def _save_to_sent(user, password, msg) -> str: + """Append a copy of the message to the account's Sent folder. Returns a status string.""" + try: + with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap: + imap.login(user, password) + raw = msg.as_bytes() + stamp = imaplib.Time2Internaldate(time.time()) + last_err = "" + for folder in SENT_FOLDER_CANDIDATES: + status, resp = imap.append(folder, r"(\Seen)", stamp, raw) + if status == "OK": + return f"saved to '{folder}'" + last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else "" + # No known folder worked — create "Sent" and try once more. + imap.create("Sent") + status, resp = imap.append("Sent", r"(\Seen)", stamp, raw) + if status == "OK": + return "saved to 'Sent' (folder created)" + return f"failed: {last_err or 'could not append to any Sent folder'}" + except Exception as e: + return f"failed: {e}" + + +def _record(request_id, recipients, subject, status, detail, sent_copy): + try: + con = db.get_db() + con.execute( + "INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)" + " VALUES (?,?,?,?,?,?,?)", + (db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy)) + con.commit() + con.close() + except Exception as e: + print(f"[mail] Could not record mail log entry: {e}") + + +def send_mail(to_addrs, subject, body, request_id=None, attachments=None, inline_images=None): + """Send an email through Purelymail, keep a copy in the Sent folder, and + record the attempt in the mail log. Returns (ok, detail). + + attachments: optional list of (filename, bytes, maintype, subtype). + inline_images: optional dict {marker_name: (bytes, subtype)}. When given, + an HTML alternative is built where each "{image:marker_name}" + in the body becomes an embedded ; the plain-text part + shows "[image: marker_name]" instead. + """ + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + user = os.environ.get("MAIL_USER", "") + password = os.environ.get("MAIL_PASSWORD", "") + + msg = EmailMessage() + msg["From"] = user or "approvalflow@localhost" + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + + if inline_images: + text = body + for name in inline_images: + text = text.replace("{image:" + name + "}", f"[image: {name}]") + msg.set_content(text) + + html = html_mod.escape(body).replace("\n", "
\n") + cids = {} + for name in inline_images: + cid = make_msgid(domain="martinhal.approvalflow") + cids[name] = cid + html = html.replace("{image:" + name + "}", + f'{html_mod.escape(name)}') + msg.add_alternative( + f'
{html}
', + subtype="html") + html_part = msg.get_payload()[-1] + for name, (data, subtype) in inline_images.items(): + html_part.add_related(data, maintype="image", subtype=subtype, cid=cids[name]) + else: + msg.set_content(body) + + for filename, data, maintype, subtype in (attachments or []): + msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) + + if not user: + # Dev mode: print instead of sending so the app is testable without an account. + att = "".join(f"\n[attachment: {f} ({len(b)} bytes)]" for f, b, *_ in (attachments or [])) + img = "".join(f"\n[inline image: {n} ({len(d)} bytes, {s})]" for n, (d, s) in (inline_images or {}).items()) + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}{att}{img}\n{'-'*50}") + _record(request_id, to_addrs, subject, "dev", + "MAIL_USER not set — printed to console", "skipped (dev mode)") + return True, "dev mode (MAIL_USER not set) — printed to console" + + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(user, password) + server.send_message(msg) + except Exception as e: + detail = f"SMTP error: {e}" + _record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)") + return False, detail + + sent_copy = _save_to_sent(user, password, msg) + _record(request_id, to_addrs, subject, "sent", "", sent_copy) + return True, f"sent to {', '.join(to_addrs)}" diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..732a911 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,236 @@ +"""Request pipeline for requests created on the site. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import mimetypes +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "created_at": req["created_at"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def _record_decision(con, request_id, step, decided_by, decision): + con.execute("INSERT INTO decisions (request_id, step, decided_by, decision, at) VALUES (?,?,?,?,?)", + (request_id, step, decided_by, decision, db.now())) + + +def build_summary(con, req, total_steps): + """Human-readable history: when the request was created and who + approved (or denied) each step, with timestamps.""" + lines = [f" Created {req['created_at']} by {req['requester']}"] + for d in con.execute("SELECT * FROM decisions WHERE request_id=? ORDER BY id", (req["id"],)): + verb = "Approved" if d["decision"] == "approved" else "DENIED" + lines.append(f" Step {d['step']}/{total_steps} {verb} {d['at']} by {d['decided_by']}") + return "\n".join(lines) + + +def collect_inline_images(rendered_body): + """Find {image:name} markers in a rendered body and load those images + from the email image library. Returns a dict for send_mail, or None.""" + import re + names = set(re.findall(r"\{image:([A-Za-z0-9._\-]+)\}", rendered_body)) + images = {} + for name in names: + ext = os.path.splitext(name)[1].lower() + subtype = db.EMAIL_IMAGE_TYPES.get(ext) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if subtype and os.path.isfile(path): + with open(path, "rb") as f: + images[name] = (f.read(), subtype) + return images or None + + +def load_attachment(req): + """Load the request's uploaded file (if any) as a send_mail attachment list.""" + if not req["attachment"]: + return None + path = db.attachment_path(req["attachment"]) + try: + with open(path, "rb") as f: + data = f.read() + except OSError: + return None + # the stored name is "_"; show the original name in the mail + original = req["attachment"].split("_", 1)[-1] + ctype = mimetypes.guess_type(original)[0] or "application/octet-stream" + maintype, subtype = ctype.split("/", 1) + return [(original, data, maintype, subtype)] + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + con.commit() # release any pending writes before slow mail I/O + v = _vars(wf, req) + body_t = wf["approval_body"] + if "{created_at}" not in body_t: # creation time is mandatory in every mail + body_t = "Request created: {created_at}\n\n" + body_t + attachments = load_attachment(req) + rendered_body = render(body_t, **v) + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + rendered_body, + request_id=request_id, + attachments=attachments, + inline_images=collect_inline_images(rendered_body)) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + summary = build_summary(con, req, len(approver_chain(wf))) + con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper(), + summary=summary, deny_reason=req["deny_reason"]) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + # These are mandatory in the outcome notice: append them if the workflow's + # custom template doesn't include the placeholder. + if new_status == "approved" and "{summary}" not in body_t: + body_t += "\n\nSummary:\n{summary}\n" + if new_status == "denied" and "{deny_reason}" not in body_t: + body_t += "\n\nReason for denial:\n{deny_reason}\n" + if "{created_at}" not in body_t and "{summary}" not in body_t: + # creation time is mandatory in every mail ({summary} already includes it) + body_t = "Request created: {created_at}\n\n" + body_t + rendered_body = render(body_t, **v) + ok, detail = send_mail(participants, render(subj_t, **v), rendered_body, + request_id=req["id"], + inline_images=collect_inline_images(rendered_body)) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by, reason=""): + """Handle a decision at the request's current step. + + A denial requires a reason (callers enforce it as a mandatory field; + this is a second line of defense). + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + reason = (reason or "").strip() + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + if not reason: + return "needreason", req, "A reason for denial is required." + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=?, deny_reason=? WHERE id=?", + (decided_by, db.now(), reason, req["id"])) + _record_decision(con, req["id"], step + 1, decided_by, "denied") + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped. Reason: {reason}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + _record_decision(con, req["id"], step + 1, decided_by, "approved") + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + con.commit() + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..73489e0 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/static/style.css @@ -0,0 +1,195 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root, [data-theme="light"] { + --paper: #fafbfc; + --card: #ffffff; + --field: #ffffff; + --hover: #f2f6f8; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --flash-ok-bg: #e4f3ee; --flash-ok-line: #bfe0d5; --flash-ok-ink: #0b5c4c; + --flash-err-bg: #f9e8e6; --flash-err-line: #edc7c3; --flash-err-ink: #872a24; + --radius: 8px; + font-size: 16px; +} +[data-theme="dark"] { + --paper: #10161c; + --card: #18212a; + --field: #10161c; + --hover: #1e2a35; + --ink: #e4ebf1; + --ink-soft: #93a5b2; + --line: #2b3945; + --approve: #35b598; + --deny: #e07067; + --pending: #d9a24a; + --focus: #6aa9e0; + --flash-ok-bg: #12352c; --flash-ok-line: #1d5a4a; --flash-ok-ink: #8fd8c3; + --flash-err-bg: #3c1f1c; --flash-err-line: #6b3530; --flash-err-ink: #f0b1aa; +} +[data-theme="dark"] body { color-scheme: dark; } +[data-theme="dark"] button, [data-theme="dark"] .btn { + border-color: var(--line); background: #26323e; +} +[data-theme="dark"] button:hover, [data-theme="dark"] .btn:hover { background: #31404e; } +[data-theme="dark"] .btn.approve { background: #14523f; border-color: #14523f; } +[data-theme="dark"] .btn.deny { background: #79312a; border-color: #79312a; } +[data-theme="dark"] .btn.ghost { background: transparent; color: var(--ink); } +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea, input[type=file] { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: var(--field); +} +input[type=file] { padding: .4rem .5rem; } +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: var(--hover); } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: var(--flash-ok-bg); border: 1px solid var(--flash-ok-line); color: var(--flash-ok-ink); } +.flash.error { background: var(--flash-err-bg); border: 1px solid var(--flash-err-line); color: var(--flash-err-ink); } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: var(--hover); border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} + +/* footer */ +.footer { + max-width: 980px; margin: 0 auto; padding: 1.2rem 1.25rem 2rem; + font-size: .8rem; color: var(--ink-soft); text-align: center; + border-top: 1px solid var(--line); +} + +/* theme toggle */ +.theme-toggle { + margin: 0; padding: .15rem .55rem; font-size: 1rem; line-height: 1; + background: transparent; border: 1px solid #4a5b68; color: #b9c6cf; + border-radius: 999px; cursor: pointer; +} +.theme-toggle:hover { color: #fff; border-color: #7fd6c2; background: transparent; } +.theme-toggle.floating { + position: fixed; top: 1rem; right: 1rem; z-index: 10; + border-color: var(--line); color: var(--ink-soft); +} +.theme-toggle.floating:hover { color: var(--ink); border-color: var(--focus); } + +/* email image library */ +.imggrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; } +.imgcard { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--field); } +.imgcard img { display: block; width: 100%; height: 110px; object-fit: contain; background: #fff; } +.imgmeta { padding: .5rem .6rem; display: flex; flex-direction: column; gap: .35rem; font-size: .78rem; } +.imgmeta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.imgmeta .tag { + background: var(--hover); border: 1px dashed var(--line); border-radius: 5px; + padding: .15rem .4rem; cursor: copy; word-break: break-all; white-space: normal; +} diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/account.html b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/account.html new file mode 100644 index 0000000..90eb590 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/account.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% block title %}Configure profile — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Configure profile

+

Signed in as {{ user.username }}

+ +
+

Profile

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Change password

+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Two-factor authentication (MFA)

+ + {% if user.mfa_secret %} +

enabled

+

Signing in requires your password and a 6-digit code from your authenticator app.

+
+ + + + +
+ + {% elif setup_secret %} +

1 — Scan this QR code with Google Authenticator, Authy, 1Password, or any TOTP app. + If you can't scan, enter the secret manually.

+
+

Secret: {{ setup_secret }}

+

2 — Enter the 6-digit code the app shows to confirm.

+
+ + + +
+
+ + +
+ + + + {% else %} +

disabled

+

Add a second sign-in step: your password plus a 6-digit code from an authenticator app on your phone.

+
+ + +
+ {% endif %} +
+{% endblock %} diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..98913ab --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + {% else %} + + {% endfor %} +
NameApproval chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..5491762 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,52 @@ + + + + + +{% block title %}MartinhalApprovalFlow{% endblock %} + + + + + + +{% if session.get('user_id') %} +
+ MartinhalApprovalFlow + +
+ + Configure profile + {{ session['username'] }} + Sign out +
+
+{% else %} + +{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+
© 2026 Martinhal IT - Joao Vaz
+ + diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..7cb6b61 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}Decision — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..1706e20 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}New request — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..6254420 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Sign in — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/mail_status.html b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/mail_status.html new file mode 100644 index 0000000..929da67 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/mail_status.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Mail status — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Mail status

+

Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.

+ +{% if not configured %} +
Mail account not configured — MAIL_USER is empty, so messages are printed to the server console (dev mode) instead of being sent.
+{% endif %} + +
+
{{ stats.get('sent', 0) }}Sent
+
{{ stats.get('error', 0) }}Errors
+
{{ copy_fails }}Sent-copy failures
+
{{ stats.get('dev', 0) }}Dev mode
+
+ +
+ + + {% for m in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
WhenToSubjectRequestDeliverySent folder
{{ m.at }}{{ m.recipients }}{{ m.subject }} + {% if m.detail %}
{{ m.detail }}{% endif %}
{% if m.request_id %}#{{ m.request_id }}{% else %}—{% endif %} + {% if m.status == 'sent' %}sent + {% elif m.status == 'error' %}error + {% else %}dev{% endif %} + + {% if m.sent_copy.startswith('saved') %}{{ m.sent_copy }} + {% elif m.sent_copy.startswith('failed') %}copy failed
{{ m.sent_copy }} + {% else %}{{ m.sent_copy }}{% endif %} +
No mail has been sent yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..1a8e5c2 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if req.attachment %} +

Attachment: + {{ req.attachment.split('_', 1)[-1] }} + (included in every approval mail)

+ {% endif %} + {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'denied' and req.deny_reason %} +

Reason for denial:

+
{{ req.deny_reason }}
+ {% endif %} + {% if req.status == 'pending' %} +
+
+ + Same effect as the current approver's email links. +
+
+
+ + + +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+ + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..3f658f9 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Users — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow. Users fill in their own details (name, unit, e-mail, MFA) on their Configure profile page; you can disable MFA here if someone loses their device.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.first_name or u.last_name %}{{ u.first_name }} {{ u.last_name }}{% endif %} + {% if u.unit_location %}{{ u.unit_location }}{% endif %} + {% if u.is_admin %}admin{% endif %} + {% if u.mfa_secret %}MFA on{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.mfa_secret %} +
+ + + +
+ {% endif %} + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..7678fc1 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,90 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+ + + +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {created_at} {summary} {deny_reason} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+ +
+

Images for email bodies

+

These images can be used in the approval request body, approved notice body and + denied notice body above. Copy an image's tag into the text where the image should appear — + it is embedded in the email at that spot. PNG, JPG or GIF, up to 2 MB each. The library is shared by all workflows.

+ + {% if images %} +
+ {% for name in images %} +
+ {{ name }} +
+ {{ name }} + {{ '{image:' + name + '}' }} +
+ + + +
+
+
+ {% endfor %} +
+ {% else %} +

No images uploaded yet.

+ {% endif %} + +
+ + + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/totp.py b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/totp.py new file mode 100644 index 0000000..909a241 --- /dev/null +++ b/OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/totp.py @@ -0,0 +1,40 @@ +"""Time-based one-time passwords (TOTP, RFC 6238) using only the standard +library. Compatible with Google Authenticator, Authy, 1Password, etc.""" +import base64 +import hashlib +import hmac +import secrets +import struct +import time +from urllib.parse import quote + +ISSUER = "MartinhalApprovalFlow" + + +def new_secret() -> str: + """A new base32 secret to enroll in an authenticator app.""" + return base64.b32encode(secrets.token_bytes(20)).decode() + + +def _code_at(secret: str, counter: int) -> str: + key = base64.b32decode(secret + "=" * (-len(secret) % 8), casefold=True) + digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 15 + number = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000 + return f"{number:06d}" + + +def verify(secret: str, code: str) -> bool: + """Check a 6-digit code, allowing one 30s step of clock drift each way.""" + code = (code or "").strip().replace(" ", "") + if not (secret and code.isdigit() and len(code) == 6): + return False + counter = int(time.time() // 30) + return any(hmac.compare_digest(_code_at(secret, counter + drift), code) + for drift in (-1, 0, 1)) + + +def otpauth_uri(username: str, secret: str) -> str: + """URI encoded as a QR code for authenticator apps.""" + return (f"otpauth://totp/{quote(ISSUER)}:{quote(username)}" + f"?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period=30") diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow.zip b/OLD_VERSIONS/v9.1 patch/approvalflow.zip new file mode 100644 index 0000000..8cb7e2e Binary files /dev/null and b/OLD_VERSIONS/v9.1 patch/approvalflow.zip differ diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..51b61a3 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/.env.example @@ -0,0 +1,18 @@ +# NOTE: Docker Compose treats '$' in this file as variable substitution. +# If any value (e.g. a password) contains a literal '$', write it as '$$': +# MAIL_PASSWORD=xK9$$vh4pQ2 -> the container receives xK9$vh4pQ2 +# Otherwise you'll see a warning like: The "..." variable is not set. + +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Purelymail account (only login and password are needed) --- +# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS. +# If Two Factor Authentication is enabled on the account, use an App Password. +# Leave MAIL_USER empty to print outgoing mail to the console (dev mode). +MAIL_USER=you@yourdomain.com +MAIL_PASSWORD=secret diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..d908d5c --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/README.md @@ -0,0 +1,116 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it. + +Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password. + +## What it does + +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Attachments.** A request can include one or more uploaded files (15 MB combined). They are attached to **every approval mail** sent along the chain, and admins can download each from the request page. Files are stored next to the database in `data/attachments/`. +- **Knowledge chain.** Next to each workflow's approval chain there is a knowledge chain: addresses that never approve anything but are informed by e-mail when a request is created and on every step approval, denial, or final approval. They are not counted as "involved" and don't receive the participant outcome notice — only their own FYI messages. +- **Configure profile.** Every user has a profile page (top-right corner): first name, last name, unit location, e-mail, change password, and MFA. The profile e-mail pre-fills the mandatory "Request's e-mail" field when submitting a request. +- **MFA.** Any user can enable two-factor authentication from their Configure profile page (TOTP — Google Authenticator, Authy, 1Password, …). Sign-in then requires password + 6-digit code. Admins can disable MFA for a user who lost their device. +- **Light and dark mode.** A toggle in the top bar (and on the login page) switches themes; the choice is remembered per browser and defaults to the system preference. +- **Images in emails.** Admins upload PNG/JPG/GIF images (up to 2 MB each) in the library shown below the Email wording section of any workflow. Placing an image's `{image:name}` tag in the approval request body, approved notice body, or denied notice body embeds the image at that spot (HTML email with a plain-text fallback). +- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +> **If a value in `.env` contains a `$`** (common in passwords), escape it as `$$` — e.g. `MAIL_PASSWORD=xK9$$vh4pQ2`. Docker Compose otherwise treats `$word` as a variable and silently replaces it with a blank string, printing a warning like `The "word" variable is not set`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account. + +## Mail server + +The app is preconfigured for Purelymail's servers — you never enter host or port settings: + +| | | +|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS | +| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS | + +Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out. + +Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password. + +## The request lifecycle + +``` +web form (login required) + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{summary}` (creation + each approval with timestamps, for the approved notice), `{deny_reason}`, `{approve_url}`, `{deny_url}`. + +## Upgrading an existing instance (no data loss) + +All schema changes are applied automatically at boot by the built-in migrations — +the SQLite database and uploaded files live in `./data/`, which is a volume and is +never touched by rebuilding the image. + +```bash +cd /path/to/approvalflow +docker compose down # stop the app (data stays in ./data) +cp -r data data.backup-$(date +%F) # safety backup of DB + attachments +# replace the code with the new version, KEEPING .env and data/: +# unzip -o approvalflow.zip -d .. (or copy the new files over the old ones) +docker compose up -d --build # rebuild image, start; migrations run at boot +docker compose logs | grep migrate # optional: see what was migrated +``` + +Roll back = stop, restore `data.backup-…` to `data/`, start the previous image. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit/mail-log helpers +pipeline.py approval chain + decision/notification logic +mailer.py Purelymail SMTP sending, Sent-folder copy, mail log +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..22bd546 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/app.py @@ -0,0 +1,579 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, send_file, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +import db +import pipeline +import totp +from mailer import send_mail +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) +# 15 MB attachment limit (+ small allowance for the rest of the form) +app.config["MAX_CONTENT_LENGTH"] = db.MAX_ATTACHMENT_BYTES + 512 * 1024 + + +@app.errorhandler(413) +def too_large(e): + flash("The uploaded files are too large — the combined limit is 15 MB.", "error") + return redirect(url_for("index")) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +def _start_session(user, next_url=None): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(next_url or url_for("index")) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + if user["mfa_secret"]: + session.clear() + session["mfa_pending"] = user["id"] + session["mfa_next"] = request.args.get("next") or "" + return redirect(url_for("login_mfa")) + return _start_session(user, request.args.get("next")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/login/mfa", methods=["GET", "POST"]) +def login_mfa(): + uid = session.get("mfa_pending") + if not uid: + return redirect(url_for("login")) + user = g.db.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() + if user is None or not user["mfa_secret"]: + session.clear() + return redirect(url_for("login")) + if request.method == "POST": + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + return _start_session(user, session.get("mfa_next") or None) + flash("Wrong verification code — try again.", "error") + return render_template("login.html", mfa_stage=True, username=user["username"]) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- account (MFA) + +@app.route("/account", methods=["GET", "POST"]) +@login_required +def account(): + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + setup_secret = session.get("mfa_setup") + + if request.method == "POST": + act = request.form.get("action") + if act == "profile": + email = request.form.get("email", "").strip().lower() + if email and "@" not in email: + flash("That e-mail address doesn't look valid.", "error") + else: + g.db.execute( + "UPDATE users SET first_name=?, last_name=?, unit_location=?, email=? WHERE id=?", + (request.form.get("first_name", "").strip(), + request.form.get("last_name", "").strip(), + request.form.get("unit_location", "").strip(), + email, user["id"])) + g.db.commit() + flash("Profile saved.", "ok") + return redirect(url_for("account")) + if act == "password": + if not check_password_hash(user["password_hash"], request.form.get("current_password", "")): + flash("Your current password is wrong.", "error") + elif not request.form.get("new_password"): + flash("The new password can't be empty.", "error") + elif request.form.get("new_password") != request.form.get("confirm_password"): + flash("The new passwords don't match.", "error") + else: + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(request.form["new_password"]), user["id"])) + g.db.commit() + flash("Password changed.", "ok") + return redirect(url_for("account")) + if act == "begin": + session["mfa_setup"] = totp.new_secret() + return redirect(url_for("account")) + if act == "cancel": + session.pop("mfa_setup", None) + return redirect(url_for("account")) + if act == "confirm" and setup_secret: + if totp.verify(setup_secret, request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret=? WHERE id=?", + (setup_secret, user["id"])) + g.db.commit() + session.pop("mfa_setup", None) + flash("Two-factor authentication is now enabled on your account.", "ok") + else: + flash("That code didn't match — scan the QR code and try again.", "error") + return redirect(url_for("account")) + if act == "disable" and user["mfa_secret"]: + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", (user["id"],)) + g.db.commit() + flash("Two-factor authentication disabled.", "ok") + else: + flash("Wrong code — MFA was not disabled.", "error") + return redirect(url_for("account")) + + otpauth = totp.otpauth_uri(user["username"], setup_secret) if setup_secret else "" + return render_template("account.html", user=user, + setup_secret=setup_secret, otpauth=otpauth) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + requester = request.form.get("request_email", "").strip().lower() + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + uploads = [(f, f.read()) for f in request.files.getlist("attachments") + if f and f.filename] + total = sum(len(data) for _, data in uploads) + if not requester or "@" not in requester: + flash("Request's e-mail is required and must be a valid address.", "error") + elif not subject or not body: + flash("Subject and message are both required.", "error") + elif total > db.MAX_ATTACHMENT_BYTES: + flash("The uploaded files are too large — the combined limit is 15 MB.", "error") + else: + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + if uploads: + os.makedirs(db.ATTACH_DIR, exist_ok=True) + entries = [] + for i, (f, data) in enumerate(uploads, 1): + name = secure_filename(f.filename) or f"attachment-{i}" + stored = f"{rid}_{i}_{name}" + with open(db.attachment_path(stored), "wb") as fh: + fh.write(data) + entries.append({"stored": stored, "name": name}) + g.db.execute("UPDATE requests SET attachment=? WHERE id=?", + (json.dumps(entries), rid)) + db.log(g.db, rid, "attachments saved", + ", ".join(f"'{e['name']}'" for e in entries) + + f" ({total} bytes total) — included in every approval mail") + g.db.commit() + pipeline.send_approval_request(rid) + pipeline.notify_knowledge(rid, f"New request created by {requester}") + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15", + (session["username"], (user["email"] or "-").lower())).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests, + user_email=user["email"] or "") + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain, + error="A reason for denial is required."), 400 + status, req2, message = pipeline.decide(token, action, decided_by, reason=reason) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + flash("A reason for denial is required.", "error") + return redirect(url_for("admin_request", rid=rid)) + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason) + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain, + attachments=pipeline.attachment_list(req)) + + +@app.route("/admin/request//attachment/") +@admin_required +def request_attachment(rid, idx): + req = g.db.execute("SELECT attachment FROM requests WHERE id=?", (rid,)).fetchone() + if req is None: + abort(404) + entries = pipeline.attachment_list(req) + if idx < 0 or idx >= len(entries): + abort(404) + path = db.attachment_path(entries[idx]["stored"]) + if not os.path.exists(path): + abort(404) + return send_file(path, as_attachment=True, download_name=entries[idx]["name"]) + + +@app.route("/admin/request//send-log", methods=["POST"]) +@admin_required +def send_request_log(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers, w.knowledge FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + to = request.form.get("email", "").strip() + if not to or "@" not in to: + flash("A valid e-mail address is required to send the log.", "error") + return redirect(url_for("admin_request", rid=rid)) + + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + chain = pipeline.approver_chain(req) + participants = json.loads(req["participants"]) + + lines = [ + f"Full log of request #{req['id']} — {req['subject']}", + "=" * 60, + f"Workflow: {req['wf_name']}", + f"Requester: {req['requester']}", + f"Created: {req['created_at']}", + f"Status: {req['status'].upper()}", + ] + if req["decided_by"]: + lines.append(f"Final decision: by {req['decided_by']} at {req['decided_at']}") + if req["deny_reason"]: + lines.append(f"Denial reason: {req['deny_reason']}") + lines += [ + f"Approval chain: {' -> '.join(chain)}", + f"Knowledge chain: {', '.join(pipeline.knowledge_chain(req)) or '(none)'}", + f"People involved: {', '.join(participants)}", + "", + "Message:", + req["body"], + "", + "Event log:", + "-" * 60, + ] + for l in logs: + lines.append(f"{l['at']} | {l['event']}") + if l["detail"]: + lines.append(f"{'':25} {l['detail']}") + body = "\n".join(lines) + + ok, detail = send_mail(to, f"[{req['wf_name']}] Full log of request #{req['id']}: {req['subject']}", + body, request_id=rid) + db.log(g.db, rid, "log sent" if ok else "mail error", + f"Full log emailed to {to} by {session['username']} — {detail}") + g.db.commit() + flash(f"Full log sent to {to}." if ok else f"Could not send the log: {detail}", + "ok" if ok else "error") + return redirect(url_for("admin_request", rid=rid)) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + knowledge = [a.strip() for a in f.get("knowledge", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, knowledge, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + json.dumps(knowledge), 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?, knowledge=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + json.dumps(knowledge), 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES, + images=_email_images()) + + +# ---------------------------------------------------------------- admin: mail status + +@app.route("/admin/mail") +@admin_required +def mail_status(): + rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall() + stats = {s["status"]: s["c"] for s in + g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")} + copy_fails = g.db.execute( + "SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"] + configured = bool(os.environ.get("MAIL_USER")) + return render_template("mail_status.html", rows=rows, stats=stats, + copy_fails=copy_fails, configured=configured) + + +# ---------------------------------------------------------------- admin: email images + +def _email_images(): + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + return sorted(f for f in os.listdir(db.EMAIL_IMG_DIR) + if os.path.splitext(f)[1].lower() in db.EMAIL_IMAGE_TYPES) + + +@app.route("/admin/email-images/upload", methods=["POST"]) +@admin_required +def email_image_upload(): + nxt = request.form.get("next") or url_for("admin") + upload = request.files.get("image") + if not upload or not upload.filename: + flash("Choose an image file to upload.", "error") + return redirect(nxt) + name = secure_filename(upload.filename) + ext = os.path.splitext(name)[1].lower() + if ext not in db.EMAIL_IMAGE_TYPES: + flash("Only PNG, JPG and GIF images can be used in emails.", "error") + return redirect(nxt) + data = upload.read() + if len(data) > db.MAX_EMAIL_IMAGE_BYTES: + flash("Email images are limited to 2 MB each.", "error") + return redirect(nxt) + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + if os.path.exists(os.path.join(db.EMAIL_IMG_DIR, name)): + flash(f"An image named “{name}” already exists — delete it first or rename the file.", "error") + return redirect(nxt) + with open(os.path.join(db.EMAIL_IMG_DIR, name), "wb") as fh: + fh.write(data) + flash(f"Image “{name}” uploaded — use it with {{image:{name}}} in the email bodies.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/delete", methods=["POST"]) +@admin_required +def email_image_delete(): + nxt = request.form.get("next") or url_for("admin") + name = secure_filename(request.form.get("name", "")) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if name and os.path.isfile(path): + os.remove(path) + flash(f"Image “{name}” deleted.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/") +@admin_required +def email_image(name): + name = secure_filename(name) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if not (name and os.path.isfile(path)): + abort(404) + return send_file(path) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "mfa_off": + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", + (f.get("user_id", type=int),)) + flash("MFA disabled for that user — they can re-enable it from their Account page.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..145b578 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/db.py @@ -0,0 +1,249 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) +ATTACH_DIR = os.path.join(os.path.dirname(DB_PATH), "attachments") +EMAIL_IMG_DIR = os.path.join(os.path.dirname(DB_PATH), "email_images") + +MAX_EMAIL_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB per email image +EMAIL_IMAGE_TYPES = {".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg", ".gif": "gif"} + +MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024 # 15 MB + + +def attachment_path(stored_name): + return os.path.join(ATTACH_DIR, stored_name) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + first_name TEXT NOT NULL DEFAULT '', + last_name TEXT NOT NULL DEFAULT '', + unit_location TEXT NOT NULL DEFAULT '', + mfa_secret TEXT NOT NULL DEFAULT '', -- empty = MFA disabled + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + knowledge TEXT NOT NULL DEFAULT '[]', -- JSON list: informed of every event, never approve + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + deny_reason TEXT NOT NULL DEFAULT '', + attachment TEXT NOT NULL DEFAULT '[]', -- JSON list of {"stored": ..., "name": ...} + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL REFERENCES requests(id) ON DELETE CASCADE, + step INTEGER NOT NULL, -- 1-based position in the chain + decided_by TEXT NOT NULL, + decision TEXT NOT NULL, -- approved / denied + at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS mail_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + request_id INTEGER, -- related request, if any + recipients TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, -- sent / error / dev + detail TEXT DEFAULT '', -- SMTP error message, if any + sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Summary:\n{summary}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Reason for denial:\n{deny_reason}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH, timeout=15) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA busy_timeout = 15000") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + if rcols and "deny_reason" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN deny_reason TEXT NOT NULL DEFAULT ''") + print("[migrate] Added deny_reason to requests.") + if rcols and "attachment" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN attachment TEXT NOT NULL DEFAULT ''") + print("[migrate] Added attachment to requests.") + ucols = {r["name"] for r in con.execute("PRAGMA table_info(users)")} + if ucols and "mfa_secret" not in ucols: + con.execute("ALTER TABLE users ADD COLUMN mfa_secret TEXT NOT NULL DEFAULT ''") + print("[migrate] Added mfa_secret to users.") + for col in ("first_name", "last_name", "unit_location"): + if ucols and col not in ucols: + con.execute(f"ALTER TABLE users ADD COLUMN {col} TEXT NOT NULL DEFAULT ''") + print(f"[migrate] Added {col} to users.") + wcols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if wcols and "knowledge" not in wcols: + con.execute("ALTER TABLE workflows ADD COLUMN knowledge TEXT NOT NULL DEFAULT '[]'") + print("[migrate] Added knowledge chain to workflows.") + # single-attachment values -> JSON list format + if rcols and "attachment" in rcols: + for row in con.execute("SELECT id, attachment FROM requests " + "WHERE attachment != '' AND attachment NOT LIKE '[%'").fetchall(): + stored = row["attachment"] + entry = [{"stored": stored, "name": stored.split("_", 1)[-1]}] + con.execute("UPDATE requests SET attachment=? WHERE id=?", + (json.dumps(entry), row["id"])) + print(f"[migrate] Converted attachment of request #{row['id']} to list format.") + con.execute("UPDATE requests SET attachment='[]' WHERE attachment=''") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..51c7fc9 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/mailer.py @@ -0,0 +1,145 @@ +"""Outbound email via Purelymail, and template rendering. + +Preconfigured for Purelymail — only the account's login and password are needed: + + MAIL_USER=you@yourdomain.com (or you@purelymail.com) + MAIL_PASSWORD=... (use an App Password if 2FA is enabled) + +Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a +copy is saved to the account's Sent folder over IMAP +(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is +recorded in the mail_log table shown on the admin "Mail status" page. +""" +import html as html_mod +import imaplib +import os +import smtplib +import time +from email.message import EmailMessage +from email.utils import make_msgid + +import db + +SMTP_HOST = "smtp.purelymail.com" +SMTP_PORT = 465 # SSL/TLS +IMAP_HOST = "imap.purelymail.com" +IMAP_PORT = 993 # SSL/TLS + +SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items") + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def _save_to_sent(user, password, msg) -> str: + """Append a copy of the message to the account's Sent folder. Returns a status string.""" + try: + with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap: + imap.login(user, password) + raw = msg.as_bytes() + stamp = imaplib.Time2Internaldate(time.time()) + last_err = "" + for folder in SENT_FOLDER_CANDIDATES: + status, resp = imap.append(folder, r"(\Seen)", stamp, raw) + if status == "OK": + return f"saved to '{folder}'" + last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else "" + # No known folder worked — create "Sent" and try once more. + imap.create("Sent") + status, resp = imap.append("Sent", r"(\Seen)", stamp, raw) + if status == "OK": + return "saved to 'Sent' (folder created)" + return f"failed: {last_err or 'could not append to any Sent folder'}" + except Exception as e: + return f"failed: {e}" + + +def _record(request_id, recipients, subject, status, detail, sent_copy): + try: + con = db.get_db() + con.execute( + "INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)" + " VALUES (?,?,?,?,?,?,?)", + (db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy)) + con.commit() + con.close() + except Exception as e: + print(f"[mail] Could not record mail log entry: {e}") + + +def send_mail(to_addrs, subject, body, request_id=None, attachments=None, inline_images=None): + """Send an email through Purelymail, keep a copy in the Sent folder, and + record the attempt in the mail log. Returns (ok, detail). + + attachments: optional list of (filename, bytes, maintype, subtype). + inline_images: optional dict {marker_name: (bytes, subtype)}. When given, + an HTML alternative is built where each "{image:marker_name}" + in the body becomes an embedded ; the plain-text part + shows "[image: marker_name]" instead. + """ + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + user = os.environ.get("MAIL_USER", "") + password = os.environ.get("MAIL_PASSWORD", "") + + msg = EmailMessage() + msg["From"] = user or "approvalflow@localhost" + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + + if inline_images: + text = body + for name in inline_images: + text = text.replace("{image:" + name + "}", f"[image: {name}]") + msg.set_content(text) + + html = html_mod.escape(body).replace("\n", "
\n") + cids = {} + for name in inline_images: + cid = make_msgid(domain="martinhal.approvalflow") + cids[name] = cid + html = html.replace("{image:" + name + "}", + f'{html_mod.escape(name)}') + msg.add_alternative( + f'
{html}
', + subtype="html") + html_part = msg.get_payload()[-1] + for name, (data, subtype) in inline_images.items(): + html_part.add_related(data, maintype="image", subtype=subtype, cid=cids[name]) + else: + msg.set_content(body) + + for filename, data, maintype, subtype in (attachments or []): + msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) + + if not user: + # Dev mode: print instead of sending so the app is testable without an account. + att = "".join(f"\n[attachment: {f} ({len(b)} bytes)]" for f, b, *_ in (attachments or [])) + img = "".join(f"\n[inline image: {n} ({len(d)} bytes, {s})]" for n, (d, s) in (inline_images or {}).items()) + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}{att}{img}\n{'-'*50}") + _record(request_id, to_addrs, subject, "dev", + "MAIL_USER not set — printed to console", "skipped (dev mode)") + return True, "dev mode (MAIL_USER not set) — printed to console" + + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(user, password) + server.send_message(msg) + except Exception as e: + detail = f"SMTP error: {e}" + _record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)") + return False, detail + + sent_copy = _save_to_sent(user, password, msg) + _record(request_id, to_addrs, subject, "sent", "", sent_copy) + return True, f"sent to {', '.join(to_addrs)}" diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..9a77bab --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,291 @@ +"""Request pipeline for requests created on the site. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import mimetypes +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "created_at": req["created_at"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def _record_decision(con, request_id, step, decided_by, decision): + con.execute("INSERT INTO decisions (request_id, step, decided_by, decision, at) VALUES (?,?,?,?,?)", + (request_id, step, decided_by, decision, db.now())) + + +def build_summary(con, req, total_steps): + """Human-readable history: when the request was created and who + approved (or denied) each step, with timestamps.""" + lines = [f" Created {req['created_at']} by {req['requester']}"] + for d in con.execute("SELECT * FROM decisions WHERE request_id=? ORDER BY id", (req["id"],)): + verb = "Approved" if d["decision"] == "approved" else "DENIED" + lines.append(f" Step {d['step']}/{total_steps} {verb} {d['at']} by {d['decided_by']}") + return "\n".join(lines) + + +def collect_inline_images(rendered_body): + """Find {image:name} markers in a rendered body and load those images + from the email image library. Returns a dict for send_mail, or None.""" + import re + names = set(re.findall(r"\{image:([A-Za-z0-9._\-]+)\}", rendered_body)) + images = {} + for name in names: + ext = os.path.splitext(name)[1].lower() + subtype = db.EMAIL_IMAGE_TYPES.get(ext) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if subtype and os.path.isfile(path): + with open(path, "rb") as f: + images[name] = (f.read(), subtype) + return images or None + + +def attachment_list(req): + """Parse the request's attachment column into [{"stored":…, "name":…}, …].""" + try: + return json.loads(req["attachment"] or "[]") + except Exception: + return [] + + +def load_attachments(req): + """Load every uploaded file of the request as send_mail attachments.""" + out = [] + for entry in attachment_list(req): + path = db.attachment_path(entry["stored"]) + try: + with open(path, "rb") as f: + data = f.read() + except OSError: + continue + ctype = mimetypes.guess_type(entry["name"])[0] or "application/octet-stream" + maintype, subtype = ctype.split("/", 1) + out.append((entry["name"], data, maintype, subtype)) + return out or None + + +def knowledge_chain(wf): + try: + chain = json.loads(wf["knowledge"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def notify_knowledge(request_id, event): + """Inform the workflow's knowledge chain of an event. These addresses are + never part of the approval chain — they only receive FYI messages.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + watchers = knowledge_chain(wf) + if not watchers: + return + chain = approver_chain(wf) + subject = f"[{wf['name']}] FYI — request #{req['id']}: {event}" + body = ( + f"For your information (you are on the knowledge chain of this workflow —\n" + f"no action is needed from you):\n\n" + f"{event}\n\n" + f"Workflow: {wf['name']}\n" + f"From: {req['requester']}\n" + f"Created: {req['created_at']}\n" + f"Subject: {req['subject']}\n" + f"Status: {req['status'].upper()}" + f" (step {min(req['current_step'], max(len(chain)-1, 0)) + 1} of {len(chain)})\n\n" + f"Message:\n{req['body']}\n" + ) + if req["deny_reason"]: + body += f"\nReason for denial:\n{req['deny_reason']}\n" + ok, detail = send_mail(watchers, subject, body, request_id=request_id) + db.log(con, request_id, + "knowledge chain informed" if ok else "mail error", + f"'{event}' to {', '.join(watchers)} — {detail}") + con.commit() + finally: + con.close() + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + con.commit() # release any pending writes before slow mail I/O + v = _vars(wf, req) + body_t = wf["approval_body"] + if "{created_at}" not in body_t: # creation time is mandatory in every mail + body_t = "Request created: {created_at}\n\n" + body_t + attachments = load_attachments(req) + rendered_body = render(body_t, **v) + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + rendered_body, + request_id=request_id, + attachments=attachments, + inline_images=collect_inline_images(rendered_body)) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + summary = build_summary(con, req, len(approver_chain(wf))) + con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper(), + summary=summary, deny_reason=req["deny_reason"]) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + # These are mandatory in the outcome notice: append them if the workflow's + # custom template doesn't include the placeholder. + if new_status == "approved" and "{summary}" not in body_t: + body_t += "\n\nSummary:\n{summary}\n" + if new_status == "denied" and "{deny_reason}" not in body_t: + body_t += "\n\nReason for denial:\n{deny_reason}\n" + if "{created_at}" not in body_t and "{summary}" not in body_t: + # creation time is mandatory in every mail ({summary} already includes it) + body_t = "Request created: {created_at}\n\n" + body_t + rendered_body = render(body_t, **v) + ok, detail = send_mail(participants, render(subj_t, **v), rendered_body, + request_id=req["id"], + inline_images=collect_inline_images(rendered_body)) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by, reason=""): + """Handle a decision at the request's current step. + + A denial requires a reason (callers enforce it as a mandatory field; + this is a second line of defense). + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + reason = (reason or "").strip() + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + if not reason: + return "needreason", req, "A reason for denial is required." + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=?, deny_reason=? WHERE id=?", + (decided_by, db.now(), reason, req["id"])) + _record_decision(con, req["id"], step + 1, decided_by, "denied") + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped. Reason: {reason}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + notify_knowledge(req["id"], f"DENIED at step {step + 1}/{len(chain)} by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + _record_decision(con, req["id"], step + 1, decided_by, "approved") + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + con.commit() + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + notify_knowledge(req["id"], + f"FULLY APPROVED — final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + notify_knowledge(req["id"], f"Step {step + 1}/{len(chain)} approved by {decided_by} — " + f"now waiting on {chain[step + 1]}") + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..73489e0 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/static/style.css @@ -0,0 +1,195 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root, [data-theme="light"] { + --paper: #fafbfc; + --card: #ffffff; + --field: #ffffff; + --hover: #f2f6f8; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --flash-ok-bg: #e4f3ee; --flash-ok-line: #bfe0d5; --flash-ok-ink: #0b5c4c; + --flash-err-bg: #f9e8e6; --flash-err-line: #edc7c3; --flash-err-ink: #872a24; + --radius: 8px; + font-size: 16px; +} +[data-theme="dark"] { + --paper: #10161c; + --card: #18212a; + --field: #10161c; + --hover: #1e2a35; + --ink: #e4ebf1; + --ink-soft: #93a5b2; + --line: #2b3945; + --approve: #35b598; + --deny: #e07067; + --pending: #d9a24a; + --focus: #6aa9e0; + --flash-ok-bg: #12352c; --flash-ok-line: #1d5a4a; --flash-ok-ink: #8fd8c3; + --flash-err-bg: #3c1f1c; --flash-err-line: #6b3530; --flash-err-ink: #f0b1aa; +} +[data-theme="dark"] body { color-scheme: dark; } +[data-theme="dark"] button, [data-theme="dark"] .btn { + border-color: var(--line); background: #26323e; +} +[data-theme="dark"] button:hover, [data-theme="dark"] .btn:hover { background: #31404e; } +[data-theme="dark"] .btn.approve { background: #14523f; border-color: #14523f; } +[data-theme="dark"] .btn.deny { background: #79312a; border-color: #79312a; } +[data-theme="dark"] .btn.ghost { background: transparent; color: var(--ink); } +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea, input[type=file] { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: var(--field); +} +input[type=file] { padding: .4rem .5rem; } +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: var(--hover); } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: var(--flash-ok-bg); border: 1px solid var(--flash-ok-line); color: var(--flash-ok-ink); } +.flash.error { background: var(--flash-err-bg); border: 1px solid var(--flash-err-line); color: var(--flash-err-ink); } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: var(--hover); border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} + +/* footer */ +.footer { + max-width: 980px; margin: 0 auto; padding: 1.2rem 1.25rem 2rem; + font-size: .8rem; color: var(--ink-soft); text-align: center; + border-top: 1px solid var(--line); +} + +/* theme toggle */ +.theme-toggle { + margin: 0; padding: .15rem .55rem; font-size: 1rem; line-height: 1; + background: transparent; border: 1px solid #4a5b68; color: #b9c6cf; + border-radius: 999px; cursor: pointer; +} +.theme-toggle:hover { color: #fff; border-color: #7fd6c2; background: transparent; } +.theme-toggle.floating { + position: fixed; top: 1rem; right: 1rem; z-index: 10; + border-color: var(--line); color: var(--ink-soft); +} +.theme-toggle.floating:hover { color: var(--ink); border-color: var(--focus); } + +/* email image library */ +.imggrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; } +.imgcard { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--field); } +.imgcard img { display: block; width: 100%; height: 110px; object-fit: contain; background: #fff; } +.imgmeta { padding: .5rem .6rem; display: flex; flex-direction: column; gap: .35rem; font-size: .78rem; } +.imgmeta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.imgmeta .tag { + background: var(--hover); border: 1px dashed var(--line); border-radius: 5px; + padding: .15rem .4rem; cursor: copy; word-break: break-all; white-space: normal; +} diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/account.html b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/account.html new file mode 100644 index 0000000..90eb590 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/account.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% block title %}Configure profile — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Configure profile

+

Signed in as {{ user.username }}

+ +
+

Profile

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Change password

+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Two-factor authentication (MFA)

+ + {% if user.mfa_secret %} +

enabled

+

Signing in requires your password and a 6-digit code from your authenticator app.

+
+ + + + +
+ + {% elif setup_secret %} +

1 — Scan this QR code with Google Authenticator, Authy, 1Password, or any TOTP app. + If you can't scan, enter the secret manually.

+
+

Secret: {{ setup_secret }}

+

2 — Enter the 6-digit code the app shows to confirm.

+
+ + + +
+
+ + +
+ + + + {% else %} +

disabled

+

Add a second sign-in step: your password plus a 6-digit code from an authenticator app on your phone.

+
+ + +
+ {% endif %} +
+{% endblock %} diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..d42a305 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + + {% else %} + + {% endfor %} +
NameApproval chainKnowledge chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% for a in w.knowledge | fromjson %}{{ a }}{% if not loop.last %}
{% endif %}{% else %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..5491762 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,52 @@ + + + + + +{% block title %}MartinhalApprovalFlow{% endblock %} + + + + + + +{% if session.get('user_id') %} +
+ MartinhalApprovalFlow + +
+ + Configure profile + {{ session['username'] }} + Sign out +
+
+{% else %} + +{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+
© 2026 Martinhal IT - Joao Vaz
+ + diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..4e54315 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Decision — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..00215d2 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}New request — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..6254420 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Sign in — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/mail_status.html b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/mail_status.html new file mode 100644 index 0000000..929da67 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/mail_status.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Mail status — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Mail status

+

Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.

+ +{% if not configured %} +
Mail account not configured — MAIL_USER is empty, so messages are printed to the server console (dev mode) instead of being sent.
+{% endif %} + +
+
{{ stats.get('sent', 0) }}Sent
+
{{ stats.get('error', 0) }}Errors
+
{{ copy_fails }}Sent-copy failures
+
{{ stats.get('dev', 0) }}Dev mode
+
+ +
+ + + {% for m in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
WhenToSubjectRequestDeliverySent folder
{{ m.at }}{{ m.recipients }}{{ m.subject }} + {% if m.detail %}
{{ m.detail }}{% endif %}
{% if m.request_id %}#{{ m.request_id }}{% else %}—{% endif %} + {% if m.status == 'sent' %}sent + {% elif m.status == 'error' %}error + {% else %}dev{% endif %} + + {% if m.sent_copy.startswith('saved') %}{{ m.sent_copy }} + {% elif m.sent_copy.startswith('failed') %}copy failed
{{ m.sent_copy }} + {% else %}{{ m.sent_copy }}{% endif %} +
No mail has been sent yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..b6e8c1c --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if attachments %} +

Attachments (included in every approval mail):
+ {% for att in attachments %} + {{ att.name }}{% if not loop.last %} · {% endif %} + {% endfor %} +

+ {% endif %} + {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'denied' and req.deny_reason %} +

Reason for denial:

+
{{ req.deny_reason }}
+ {% endif %} + {% if req.status == 'pending' %} +
+
+ + Same effect as the current approver's email links. +
+
+
+ + + +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+ + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..3f658f9 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Users — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow. Users fill in their own details (name, unit, e-mail, MFA) on their Configure profile page; you can disable MFA here if someone loses their device.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.first_name or u.last_name %}{{ u.first_name }} {{ u.last_name }}{% endif %} + {% if u.unit_location %}{{ u.unit_location }}{% endif %} + {% if u.is_admin %}admin{% endif %} + {% if u.mfa_secret %}MFA on{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.mfa_secret %} +
+ + + +
+ {% endif %} + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..47202ae --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {created_at} {summary} {deny_reason} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+ +
+

Images for email bodies

+

These images can be used in the approval request body, approved notice body and + denied notice body above. Copy an image's tag into the text where the image should appear — + it is embedded in the email at that spot. PNG, JPG or GIF, up to 2 MB each. The library is shared by all workflows.

+ + {% if images %} +
+ {% for name in images %} +
+ {{ name }} +
+ {{ name }} + {{ '{image:' + name + '}' }} +
+ + + +
+
+
+ {% endfor %} +
+ {% else %} +

No images uploaded yet.

+ {% endif %} + +
+ + + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/totp.py b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/totp.py new file mode 100644 index 0000000..909a241 --- /dev/null +++ b/OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/totp.py @@ -0,0 +1,40 @@ +"""Time-based one-time passwords (TOTP, RFC 6238) using only the standard +library. Compatible with Google Authenticator, Authy, 1Password, etc.""" +import base64 +import hashlib +import hmac +import secrets +import struct +import time +from urllib.parse import quote + +ISSUER = "MartinhalApprovalFlow" + + +def new_secret() -> str: + """A new base32 secret to enroll in an authenticator app.""" + return base64.b32encode(secrets.token_bytes(20)).decode() + + +def _code_at(secret: str, counter: int) -> str: + key = base64.b32decode(secret + "=" * (-len(secret) % 8), casefold=True) + digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 15 + number = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000 + return f"{number:06d}" + + +def verify(secret: str, code: str) -> bool: + """Check a 6-digit code, allowing one 30s step of clock drift each way.""" + code = (code or "").strip().replace(" ", "") + if not (secret and code.isdigit() and len(code) == 6): + return False + counter = int(time.time() // 30) + return any(hmac.compare_digest(_code_at(secret, counter + drift), code) + for drift in (-1, 0, 1)) + + +def otpauth_uri(username: str, secret: str) -> str: + """URI encoded as a QR code for authenticator apps.""" + return (f"otpauth://totp/{quote(ISSUER)}:{quote(username)}" + f"?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period=30") diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow.zip b/OLD_VERSIONS/v9.2 patch/approvalflow.zip new file mode 100644 index 0000000..9d22469 Binary files /dev/null and b/OLD_VERSIONS/v9.2 patch/approvalflow.zip differ diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..51b61a3 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/.env.example @@ -0,0 +1,18 @@ +# NOTE: Docker Compose treats '$' in this file as variable substitution. +# If any value (e.g. a password) contains a literal '$', write it as '$$': +# MAIL_PASSWORD=xK9$$vh4pQ2 -> the container receives xK9$vh4pQ2 +# Otherwise you'll see a warning like: The "..." variable is not set. + +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Purelymail account (only login and password are needed) --- +# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS. +# If Two Factor Authentication is enabled on the account, use an App Password. +# Leave MAIL_USER empty to print outgoing mail to the console (dev mode). +MAIL_USER=you@yourdomain.com +MAIL_PASSWORD=secret diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..d908d5c --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/README.md @@ -0,0 +1,116 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it. + +Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password. + +## What it does + +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Attachments.** A request can include one or more uploaded files (15 MB combined). They are attached to **every approval mail** sent along the chain, and admins can download each from the request page. Files are stored next to the database in `data/attachments/`. +- **Knowledge chain.** Next to each workflow's approval chain there is a knowledge chain: addresses that never approve anything but are informed by e-mail when a request is created and on every step approval, denial, or final approval. They are not counted as "involved" and don't receive the participant outcome notice — only their own FYI messages. +- **Configure profile.** Every user has a profile page (top-right corner): first name, last name, unit location, e-mail, change password, and MFA. The profile e-mail pre-fills the mandatory "Request's e-mail" field when submitting a request. +- **MFA.** Any user can enable two-factor authentication from their Configure profile page (TOTP — Google Authenticator, Authy, 1Password, …). Sign-in then requires password + 6-digit code. Admins can disable MFA for a user who lost their device. +- **Light and dark mode.** A toggle in the top bar (and on the login page) switches themes; the choice is remembered per browser and defaults to the system preference. +- **Images in emails.** Admins upload PNG/JPG/GIF images (up to 2 MB each) in the library shown below the Email wording section of any workflow. Placing an image's `{image:name}` tag in the approval request body, approved notice body, or denied notice body embeds the image at that spot (HTML email with a plain-text fallback). +- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +> **If a value in `.env` contains a `$`** (common in passwords), escape it as `$$` — e.g. `MAIL_PASSWORD=xK9$$vh4pQ2`. Docker Compose otherwise treats `$word` as a variable and silently replaces it with a blank string, printing a warning like `The "word" variable is not set`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account. + +## Mail server + +The app is preconfigured for Purelymail's servers — you never enter host or port settings: + +| | | +|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS | +| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS | + +Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out. + +Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password. + +## The request lifecycle + +``` +web form (login required) + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{summary}` (creation + each approval with timestamps, for the approved notice), `{deny_reason}`, `{approve_url}`, `{deny_url}`. + +## Upgrading an existing instance (no data loss) + +All schema changes are applied automatically at boot by the built-in migrations — +the SQLite database and uploaded files live in `./data/`, which is a volume and is +never touched by rebuilding the image. + +```bash +cd /path/to/approvalflow +docker compose down # stop the app (data stays in ./data) +cp -r data data.backup-$(date +%F) # safety backup of DB + attachments +# replace the code with the new version, KEEPING .env and data/: +# unzip -o approvalflow.zip -d .. (or copy the new files over the old ones) +docker compose up -d --build # rebuild image, start; migrations run at boot +docker compose logs | grep migrate # optional: see what was migrated +``` + +Roll back = stop, restore `data.backup-…` to `data/`, start the previous image. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit/mail-log helpers +pipeline.py approval chain + decision/notification logic +mailer.py Purelymail SMTP sending, Sent-folder copy, mail log +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..22bd546 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/app.py @@ -0,0 +1,579 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, send_file, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +import db +import pipeline +import totp +from mailer import send_mail +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) +# 15 MB attachment limit (+ small allowance for the rest of the form) +app.config["MAX_CONTENT_LENGTH"] = db.MAX_ATTACHMENT_BYTES + 512 * 1024 + + +@app.errorhandler(413) +def too_large(e): + flash("The uploaded files are too large — the combined limit is 15 MB.", "error") + return redirect(url_for("index")) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +def _start_session(user, next_url=None): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(next_url or url_for("index")) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + if user["mfa_secret"]: + session.clear() + session["mfa_pending"] = user["id"] + session["mfa_next"] = request.args.get("next") or "" + return redirect(url_for("login_mfa")) + return _start_session(user, request.args.get("next")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/login/mfa", methods=["GET", "POST"]) +def login_mfa(): + uid = session.get("mfa_pending") + if not uid: + return redirect(url_for("login")) + user = g.db.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() + if user is None or not user["mfa_secret"]: + session.clear() + return redirect(url_for("login")) + if request.method == "POST": + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + return _start_session(user, session.get("mfa_next") or None) + flash("Wrong verification code — try again.", "error") + return render_template("login.html", mfa_stage=True, username=user["username"]) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- account (MFA) + +@app.route("/account", methods=["GET", "POST"]) +@login_required +def account(): + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + setup_secret = session.get("mfa_setup") + + if request.method == "POST": + act = request.form.get("action") + if act == "profile": + email = request.form.get("email", "").strip().lower() + if email and "@" not in email: + flash("That e-mail address doesn't look valid.", "error") + else: + g.db.execute( + "UPDATE users SET first_name=?, last_name=?, unit_location=?, email=? WHERE id=?", + (request.form.get("first_name", "").strip(), + request.form.get("last_name", "").strip(), + request.form.get("unit_location", "").strip(), + email, user["id"])) + g.db.commit() + flash("Profile saved.", "ok") + return redirect(url_for("account")) + if act == "password": + if not check_password_hash(user["password_hash"], request.form.get("current_password", "")): + flash("Your current password is wrong.", "error") + elif not request.form.get("new_password"): + flash("The new password can't be empty.", "error") + elif request.form.get("new_password") != request.form.get("confirm_password"): + flash("The new passwords don't match.", "error") + else: + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(request.form["new_password"]), user["id"])) + g.db.commit() + flash("Password changed.", "ok") + return redirect(url_for("account")) + if act == "begin": + session["mfa_setup"] = totp.new_secret() + return redirect(url_for("account")) + if act == "cancel": + session.pop("mfa_setup", None) + return redirect(url_for("account")) + if act == "confirm" and setup_secret: + if totp.verify(setup_secret, request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret=? WHERE id=?", + (setup_secret, user["id"])) + g.db.commit() + session.pop("mfa_setup", None) + flash("Two-factor authentication is now enabled on your account.", "ok") + else: + flash("That code didn't match — scan the QR code and try again.", "error") + return redirect(url_for("account")) + if act == "disable" and user["mfa_secret"]: + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", (user["id"],)) + g.db.commit() + flash("Two-factor authentication disabled.", "ok") + else: + flash("Wrong code — MFA was not disabled.", "error") + return redirect(url_for("account")) + + otpauth = totp.otpauth_uri(user["username"], setup_secret) if setup_secret else "" + return render_template("account.html", user=user, + setup_secret=setup_secret, otpauth=otpauth) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + requester = request.form.get("request_email", "").strip().lower() + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + uploads = [(f, f.read()) for f in request.files.getlist("attachments") + if f and f.filename] + total = sum(len(data) for _, data in uploads) + if not requester or "@" not in requester: + flash("Request's e-mail is required and must be a valid address.", "error") + elif not subject or not body: + flash("Subject and message are both required.", "error") + elif total > db.MAX_ATTACHMENT_BYTES: + flash("The uploaded files are too large — the combined limit is 15 MB.", "error") + else: + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + if uploads: + os.makedirs(db.ATTACH_DIR, exist_ok=True) + entries = [] + for i, (f, data) in enumerate(uploads, 1): + name = secure_filename(f.filename) or f"attachment-{i}" + stored = f"{rid}_{i}_{name}" + with open(db.attachment_path(stored), "wb") as fh: + fh.write(data) + entries.append({"stored": stored, "name": name}) + g.db.execute("UPDATE requests SET attachment=? WHERE id=?", + (json.dumps(entries), rid)) + db.log(g.db, rid, "attachments saved", + ", ".join(f"'{e['name']}'" for e in entries) + + f" ({total} bytes total) — included in every approval mail") + g.db.commit() + pipeline.send_approval_request(rid) + pipeline.notify_knowledge(rid, f"New request created by {requester}") + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15", + (session["username"], (user["email"] or "-").lower())).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests, + user_email=user["email"] or "") + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain, + error="A reason for denial is required."), 400 + status, req2, message = pipeline.decide(token, action, decided_by, reason=reason) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + flash("A reason for denial is required.", "error") + return redirect(url_for("admin_request", rid=rid)) + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason) + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain, + attachments=pipeline.attachment_list(req)) + + +@app.route("/admin/request//attachment/") +@admin_required +def request_attachment(rid, idx): + req = g.db.execute("SELECT attachment FROM requests WHERE id=?", (rid,)).fetchone() + if req is None: + abort(404) + entries = pipeline.attachment_list(req) + if idx < 0 or idx >= len(entries): + abort(404) + path = db.attachment_path(entries[idx]["stored"]) + if not os.path.exists(path): + abort(404) + return send_file(path, as_attachment=True, download_name=entries[idx]["name"]) + + +@app.route("/admin/request//send-log", methods=["POST"]) +@admin_required +def send_request_log(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers, w.knowledge FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + to = request.form.get("email", "").strip() + if not to or "@" not in to: + flash("A valid e-mail address is required to send the log.", "error") + return redirect(url_for("admin_request", rid=rid)) + + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + chain = pipeline.approver_chain(req) + participants = json.loads(req["participants"]) + + lines = [ + f"Full log of request #{req['id']} — {req['subject']}", + "=" * 60, + f"Workflow: {req['wf_name']}", + f"Requester: {req['requester']}", + f"Created: {req['created_at']}", + f"Status: {req['status'].upper()}", + ] + if req["decided_by"]: + lines.append(f"Final decision: by {req['decided_by']} at {req['decided_at']}") + if req["deny_reason"]: + lines.append(f"Denial reason: {req['deny_reason']}") + lines += [ + f"Approval chain: {' -> '.join(chain)}", + f"Knowledge chain: {', '.join(pipeline.knowledge_chain(req)) or '(none)'}", + f"People involved: {', '.join(participants)}", + "", + "Message:", + req["body"], + "", + "Event log:", + "-" * 60, + ] + for l in logs: + lines.append(f"{l['at']} | {l['event']}") + if l["detail"]: + lines.append(f"{'':25} {l['detail']}") + body = "\n".join(lines) + + ok, detail = send_mail(to, f"[{req['wf_name']}] Full log of request #{req['id']}: {req['subject']}", + body, request_id=rid) + db.log(g.db, rid, "log sent" if ok else "mail error", + f"Full log emailed to {to} by {session['username']} — {detail}") + g.db.commit() + flash(f"Full log sent to {to}." if ok else f"Could not send the log: {detail}", + "ok" if ok else "error") + return redirect(url_for("admin_request", rid=rid)) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + knowledge = [a.strip() for a in f.get("knowledge", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, knowledge, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + json.dumps(knowledge), 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?, knowledge=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + json.dumps(knowledge), 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES, + images=_email_images()) + + +# ---------------------------------------------------------------- admin: mail status + +@app.route("/admin/mail") +@admin_required +def mail_status(): + rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall() + stats = {s["status"]: s["c"] for s in + g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")} + copy_fails = g.db.execute( + "SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"] + configured = bool(os.environ.get("MAIL_USER")) + return render_template("mail_status.html", rows=rows, stats=stats, + copy_fails=copy_fails, configured=configured) + + +# ---------------------------------------------------------------- admin: email images + +def _email_images(): + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + return sorted(f for f in os.listdir(db.EMAIL_IMG_DIR) + if os.path.splitext(f)[1].lower() in db.EMAIL_IMAGE_TYPES) + + +@app.route("/admin/email-images/upload", methods=["POST"]) +@admin_required +def email_image_upload(): + nxt = request.form.get("next") or url_for("admin") + upload = request.files.get("image") + if not upload or not upload.filename: + flash("Choose an image file to upload.", "error") + return redirect(nxt) + name = secure_filename(upload.filename) + ext = os.path.splitext(name)[1].lower() + if ext not in db.EMAIL_IMAGE_TYPES: + flash("Only PNG, JPG and GIF images can be used in emails.", "error") + return redirect(nxt) + data = upload.read() + if len(data) > db.MAX_EMAIL_IMAGE_BYTES: + flash("Email images are limited to 2 MB each.", "error") + return redirect(nxt) + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + if os.path.exists(os.path.join(db.EMAIL_IMG_DIR, name)): + flash(f"An image named “{name}” already exists — delete it first or rename the file.", "error") + return redirect(nxt) + with open(os.path.join(db.EMAIL_IMG_DIR, name), "wb") as fh: + fh.write(data) + flash(f"Image “{name}” uploaded — use it with {{image:{name}}} in the email bodies.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/delete", methods=["POST"]) +@admin_required +def email_image_delete(): + nxt = request.form.get("next") or url_for("admin") + name = secure_filename(request.form.get("name", "")) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if name and os.path.isfile(path): + os.remove(path) + flash(f"Image “{name}” deleted.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/") +@admin_required +def email_image(name): + name = secure_filename(name) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if not (name and os.path.isfile(path)): + abort(404) + return send_file(path) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "mfa_off": + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", + (f.get("user_id", type=int),)) + flash("MFA disabled for that user — they can re-enable it from their Account page.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..145b578 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/db.py @@ -0,0 +1,249 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) +ATTACH_DIR = os.path.join(os.path.dirname(DB_PATH), "attachments") +EMAIL_IMG_DIR = os.path.join(os.path.dirname(DB_PATH), "email_images") + +MAX_EMAIL_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB per email image +EMAIL_IMAGE_TYPES = {".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg", ".gif": "gif"} + +MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024 # 15 MB + + +def attachment_path(stored_name): + return os.path.join(ATTACH_DIR, stored_name) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + first_name TEXT NOT NULL DEFAULT '', + last_name TEXT NOT NULL DEFAULT '', + unit_location TEXT NOT NULL DEFAULT '', + mfa_secret TEXT NOT NULL DEFAULT '', -- empty = MFA disabled + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + knowledge TEXT NOT NULL DEFAULT '[]', -- JSON list: informed of every event, never approve + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + deny_reason TEXT NOT NULL DEFAULT '', + attachment TEXT NOT NULL DEFAULT '[]', -- JSON list of {"stored": ..., "name": ...} + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL REFERENCES requests(id) ON DELETE CASCADE, + step INTEGER NOT NULL, -- 1-based position in the chain + decided_by TEXT NOT NULL, + decision TEXT NOT NULL, -- approved / denied + at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS mail_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + request_id INTEGER, -- related request, if any + recipients TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, -- sent / error / dev + detail TEXT DEFAULT '', -- SMTP error message, if any + sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Summary:\n{summary}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Reason for denial:\n{deny_reason}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH, timeout=15) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA busy_timeout = 15000") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + if rcols and "deny_reason" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN deny_reason TEXT NOT NULL DEFAULT ''") + print("[migrate] Added deny_reason to requests.") + if rcols and "attachment" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN attachment TEXT NOT NULL DEFAULT ''") + print("[migrate] Added attachment to requests.") + ucols = {r["name"] for r in con.execute("PRAGMA table_info(users)")} + if ucols and "mfa_secret" not in ucols: + con.execute("ALTER TABLE users ADD COLUMN mfa_secret TEXT NOT NULL DEFAULT ''") + print("[migrate] Added mfa_secret to users.") + for col in ("first_name", "last_name", "unit_location"): + if ucols and col not in ucols: + con.execute(f"ALTER TABLE users ADD COLUMN {col} TEXT NOT NULL DEFAULT ''") + print(f"[migrate] Added {col} to users.") + wcols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if wcols and "knowledge" not in wcols: + con.execute("ALTER TABLE workflows ADD COLUMN knowledge TEXT NOT NULL DEFAULT '[]'") + print("[migrate] Added knowledge chain to workflows.") + # single-attachment values -> JSON list format + if rcols and "attachment" in rcols: + for row in con.execute("SELECT id, attachment FROM requests " + "WHERE attachment != '' AND attachment NOT LIKE '[%'").fetchall(): + stored = row["attachment"] + entry = [{"stored": stored, "name": stored.split("_", 1)[-1]}] + con.execute("UPDATE requests SET attachment=? WHERE id=?", + (json.dumps(entry), row["id"])) + print(f"[migrate] Converted attachment of request #{row['id']} to list format.") + con.execute("UPDATE requests SET attachment='[]' WHERE attachment=''") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..51c7fc9 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/mailer.py @@ -0,0 +1,145 @@ +"""Outbound email via Purelymail, and template rendering. + +Preconfigured for Purelymail — only the account's login and password are needed: + + MAIL_USER=you@yourdomain.com (or you@purelymail.com) + MAIL_PASSWORD=... (use an App Password if 2FA is enabled) + +Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a +copy is saved to the account's Sent folder over IMAP +(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is +recorded in the mail_log table shown on the admin "Mail status" page. +""" +import html as html_mod +import imaplib +import os +import smtplib +import time +from email.message import EmailMessage +from email.utils import make_msgid + +import db + +SMTP_HOST = "smtp.purelymail.com" +SMTP_PORT = 465 # SSL/TLS +IMAP_HOST = "imap.purelymail.com" +IMAP_PORT = 993 # SSL/TLS + +SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items") + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def _save_to_sent(user, password, msg) -> str: + """Append a copy of the message to the account's Sent folder. Returns a status string.""" + try: + with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap: + imap.login(user, password) + raw = msg.as_bytes() + stamp = imaplib.Time2Internaldate(time.time()) + last_err = "" + for folder in SENT_FOLDER_CANDIDATES: + status, resp = imap.append(folder, r"(\Seen)", stamp, raw) + if status == "OK": + return f"saved to '{folder}'" + last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else "" + # No known folder worked — create "Sent" and try once more. + imap.create("Sent") + status, resp = imap.append("Sent", r"(\Seen)", stamp, raw) + if status == "OK": + return "saved to 'Sent' (folder created)" + return f"failed: {last_err or 'could not append to any Sent folder'}" + except Exception as e: + return f"failed: {e}" + + +def _record(request_id, recipients, subject, status, detail, sent_copy): + try: + con = db.get_db() + con.execute( + "INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)" + " VALUES (?,?,?,?,?,?,?)", + (db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy)) + con.commit() + con.close() + except Exception as e: + print(f"[mail] Could not record mail log entry: {e}") + + +def send_mail(to_addrs, subject, body, request_id=None, attachments=None, inline_images=None): + """Send an email through Purelymail, keep a copy in the Sent folder, and + record the attempt in the mail log. Returns (ok, detail). + + attachments: optional list of (filename, bytes, maintype, subtype). + inline_images: optional dict {marker_name: (bytes, subtype)}. When given, + an HTML alternative is built where each "{image:marker_name}" + in the body becomes an embedded ; the plain-text part + shows "[image: marker_name]" instead. + """ + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + user = os.environ.get("MAIL_USER", "") + password = os.environ.get("MAIL_PASSWORD", "") + + msg = EmailMessage() + msg["From"] = user or "approvalflow@localhost" + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + + if inline_images: + text = body + for name in inline_images: + text = text.replace("{image:" + name + "}", f"[image: {name}]") + msg.set_content(text) + + html = html_mod.escape(body).replace("\n", "
\n") + cids = {} + for name in inline_images: + cid = make_msgid(domain="martinhal.approvalflow") + cids[name] = cid + html = html.replace("{image:" + name + "}", + f'{html_mod.escape(name)}') + msg.add_alternative( + f'
{html}
', + subtype="html") + html_part = msg.get_payload()[-1] + for name, (data, subtype) in inline_images.items(): + html_part.add_related(data, maintype="image", subtype=subtype, cid=cids[name]) + else: + msg.set_content(body) + + for filename, data, maintype, subtype in (attachments or []): + msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) + + if not user: + # Dev mode: print instead of sending so the app is testable without an account. + att = "".join(f"\n[attachment: {f} ({len(b)} bytes)]" for f, b, *_ in (attachments or [])) + img = "".join(f"\n[inline image: {n} ({len(d)} bytes, {s})]" for n, (d, s) in (inline_images or {}).items()) + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}{att}{img}\n{'-'*50}") + _record(request_id, to_addrs, subject, "dev", + "MAIL_USER not set — printed to console", "skipped (dev mode)") + return True, "dev mode (MAIL_USER not set) — printed to console" + + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(user, password) + server.send_message(msg) + except Exception as e: + detail = f"SMTP error: {e}" + _record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)") + return False, detail + + sent_copy = _save_to_sent(user, password, msg) + _record(request_id, to_addrs, subject, "sent", "", sent_copy) + return True, f"sent to {', '.join(to_addrs)}" diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..9a77bab --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,291 @@ +"""Request pipeline for requests created on the site. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import mimetypes +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "created_at": req["created_at"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def _record_decision(con, request_id, step, decided_by, decision): + con.execute("INSERT INTO decisions (request_id, step, decided_by, decision, at) VALUES (?,?,?,?,?)", + (request_id, step, decided_by, decision, db.now())) + + +def build_summary(con, req, total_steps): + """Human-readable history: when the request was created and who + approved (or denied) each step, with timestamps.""" + lines = [f" Created {req['created_at']} by {req['requester']}"] + for d in con.execute("SELECT * FROM decisions WHERE request_id=? ORDER BY id", (req["id"],)): + verb = "Approved" if d["decision"] == "approved" else "DENIED" + lines.append(f" Step {d['step']}/{total_steps} {verb} {d['at']} by {d['decided_by']}") + return "\n".join(lines) + + +def collect_inline_images(rendered_body): + """Find {image:name} markers in a rendered body and load those images + from the email image library. Returns a dict for send_mail, or None.""" + import re + names = set(re.findall(r"\{image:([A-Za-z0-9._\-]+)\}", rendered_body)) + images = {} + for name in names: + ext = os.path.splitext(name)[1].lower() + subtype = db.EMAIL_IMAGE_TYPES.get(ext) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if subtype and os.path.isfile(path): + with open(path, "rb") as f: + images[name] = (f.read(), subtype) + return images or None + + +def attachment_list(req): + """Parse the request's attachment column into [{"stored":…, "name":…}, …].""" + try: + return json.loads(req["attachment"] or "[]") + except Exception: + return [] + + +def load_attachments(req): + """Load every uploaded file of the request as send_mail attachments.""" + out = [] + for entry in attachment_list(req): + path = db.attachment_path(entry["stored"]) + try: + with open(path, "rb") as f: + data = f.read() + except OSError: + continue + ctype = mimetypes.guess_type(entry["name"])[0] or "application/octet-stream" + maintype, subtype = ctype.split("/", 1) + out.append((entry["name"], data, maintype, subtype)) + return out or None + + +def knowledge_chain(wf): + try: + chain = json.loads(wf["knowledge"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def notify_knowledge(request_id, event): + """Inform the workflow's knowledge chain of an event. These addresses are + never part of the approval chain — they only receive FYI messages.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + watchers = knowledge_chain(wf) + if not watchers: + return + chain = approver_chain(wf) + subject = f"[{wf['name']}] FYI — request #{req['id']}: {event}" + body = ( + f"For your information (you are on the knowledge chain of this workflow —\n" + f"no action is needed from you):\n\n" + f"{event}\n\n" + f"Workflow: {wf['name']}\n" + f"From: {req['requester']}\n" + f"Created: {req['created_at']}\n" + f"Subject: {req['subject']}\n" + f"Status: {req['status'].upper()}" + f" (step {min(req['current_step'], max(len(chain)-1, 0)) + 1} of {len(chain)})\n\n" + f"Message:\n{req['body']}\n" + ) + if req["deny_reason"]: + body += f"\nReason for denial:\n{req['deny_reason']}\n" + ok, detail = send_mail(watchers, subject, body, request_id=request_id) + db.log(con, request_id, + "knowledge chain informed" if ok else "mail error", + f"'{event}' to {', '.join(watchers)} — {detail}") + con.commit() + finally: + con.close() + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + con.commit() # release any pending writes before slow mail I/O + v = _vars(wf, req) + body_t = wf["approval_body"] + if "{created_at}" not in body_t: # creation time is mandatory in every mail + body_t = "Request created: {created_at}\n\n" + body_t + attachments = load_attachments(req) + rendered_body = render(body_t, **v) + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + rendered_body, + request_id=request_id, + attachments=attachments, + inline_images=collect_inline_images(rendered_body)) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + summary = build_summary(con, req, len(approver_chain(wf))) + con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper(), + summary=summary, deny_reason=req["deny_reason"]) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + # These are mandatory in the outcome notice: append them if the workflow's + # custom template doesn't include the placeholder. + if new_status == "approved" and "{summary}" not in body_t: + body_t += "\n\nSummary:\n{summary}\n" + if new_status == "denied" and "{deny_reason}" not in body_t: + body_t += "\n\nReason for denial:\n{deny_reason}\n" + if "{created_at}" not in body_t and "{summary}" not in body_t: + # creation time is mandatory in every mail ({summary} already includes it) + body_t = "Request created: {created_at}\n\n" + body_t + rendered_body = render(body_t, **v) + ok, detail = send_mail(participants, render(subj_t, **v), rendered_body, + request_id=req["id"], + inline_images=collect_inline_images(rendered_body)) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by, reason=""): + """Handle a decision at the request's current step. + + A denial requires a reason (callers enforce it as a mandatory field; + this is a second line of defense). + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + reason = (reason or "").strip() + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + if not reason: + return "needreason", req, "A reason for denial is required." + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=?, deny_reason=? WHERE id=?", + (decided_by, db.now(), reason, req["id"])) + _record_decision(con, req["id"], step + 1, decided_by, "denied") + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped. Reason: {reason}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + notify_knowledge(req["id"], f"DENIED at step {step + 1}/{len(chain)} by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + _record_decision(con, req["id"], step + 1, decided_by, "approved") + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + con.commit() + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + notify_knowledge(req["id"], + f"FULLY APPROVED — final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + notify_knowledge(req["id"], f"Step {step + 1}/{len(chain)} approved by {decided_by} — " + f"now waiting on {chain[step + 1]}") + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..73489e0 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/static/style.css @@ -0,0 +1,195 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root, [data-theme="light"] { + --paper: #fafbfc; + --card: #ffffff; + --field: #ffffff; + --hover: #f2f6f8; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --flash-ok-bg: #e4f3ee; --flash-ok-line: #bfe0d5; --flash-ok-ink: #0b5c4c; + --flash-err-bg: #f9e8e6; --flash-err-line: #edc7c3; --flash-err-ink: #872a24; + --radius: 8px; + font-size: 16px; +} +[data-theme="dark"] { + --paper: #10161c; + --card: #18212a; + --field: #10161c; + --hover: #1e2a35; + --ink: #e4ebf1; + --ink-soft: #93a5b2; + --line: #2b3945; + --approve: #35b598; + --deny: #e07067; + --pending: #d9a24a; + --focus: #6aa9e0; + --flash-ok-bg: #12352c; --flash-ok-line: #1d5a4a; --flash-ok-ink: #8fd8c3; + --flash-err-bg: #3c1f1c; --flash-err-line: #6b3530; --flash-err-ink: #f0b1aa; +} +[data-theme="dark"] body { color-scheme: dark; } +[data-theme="dark"] button, [data-theme="dark"] .btn { + border-color: var(--line); background: #26323e; +} +[data-theme="dark"] button:hover, [data-theme="dark"] .btn:hover { background: #31404e; } +[data-theme="dark"] .btn.approve { background: #14523f; border-color: #14523f; } +[data-theme="dark"] .btn.deny { background: #79312a; border-color: #79312a; } +[data-theme="dark"] .btn.ghost { background: transparent; color: var(--ink); } +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea, input[type=file] { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: var(--field); +} +input[type=file] { padding: .4rem .5rem; } +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: var(--hover); } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: var(--flash-ok-bg); border: 1px solid var(--flash-ok-line); color: var(--flash-ok-ink); } +.flash.error { background: var(--flash-err-bg); border: 1px solid var(--flash-err-line); color: var(--flash-err-ink); } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: var(--hover); border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} + +/* footer */ +.footer { + max-width: 980px; margin: 0 auto; padding: 1.2rem 1.25rem 2rem; + font-size: .8rem; color: var(--ink-soft); text-align: center; + border-top: 1px solid var(--line); +} + +/* theme toggle */ +.theme-toggle { + margin: 0; padding: .15rem .55rem; font-size: 1rem; line-height: 1; + background: transparent; border: 1px solid #4a5b68; color: #b9c6cf; + border-radius: 999px; cursor: pointer; +} +.theme-toggle:hover { color: #fff; border-color: #7fd6c2; background: transparent; } +.theme-toggle.floating { + position: fixed; top: 1rem; right: 1rem; z-index: 10; + border-color: var(--line); color: var(--ink-soft); +} +.theme-toggle.floating:hover { color: var(--ink); border-color: var(--focus); } + +/* email image library */ +.imggrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; } +.imgcard { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--field); } +.imgcard img { display: block; width: 100%; height: 110px; object-fit: contain; background: #fff; } +.imgmeta { padding: .5rem .6rem; display: flex; flex-direction: column; gap: .35rem; font-size: .78rem; } +.imgmeta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.imgmeta .tag { + background: var(--hover); border: 1px dashed var(--line); border-radius: 5px; + padding: .15rem .4rem; cursor: copy; word-break: break-all; white-space: normal; +} diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/account.html b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/account.html new file mode 100644 index 0000000..90eb590 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/account.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% block title %}Configure profile — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Configure profile

+

Signed in as {{ user.username }}

+ +
+

Profile

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Change password

+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Two-factor authentication (MFA)

+ + {% if user.mfa_secret %} +

enabled

+

Signing in requires your password and a 6-digit code from your authenticator app.

+
+ + + + +
+ + {% elif setup_secret %} +

1 — Scan this QR code with Google Authenticator, Authy, 1Password, or any TOTP app. + If you can't scan, enter the secret manually.

+
+

Secret: {{ setup_secret }}

+

2 — Enter the 6-digit code the app shows to confirm.

+
+ + + +
+
+ + +
+ + + + {% else %} +

disabled

+

Add a second sign-in step: your password plus a 6-digit code from an authenticator app on your phone.

+
+ + +
+ {% endif %} +
+{% endblock %} diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..d42a305 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + + {% else %} + + {% endfor %} +
NameApproval chainKnowledge chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% for a in w.knowledge | fromjson %}{{ a }}{% if not loop.last %}
{% endif %}{% else %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..208d9af --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,52 @@ + + + + + +{% block title %}MartinhalApprovalFlow{% endblock %} + + + + + + +{% if session.get('user_id') %} +
+ MartinhalApprovalFlow + +
+ + Configure profile + {{ session['username'] }} + Sign out +
+
+{% else %} + +{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+
© 2026 Martinhal IT - Joao Vaz - Version 9.2
+ + diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..4e54315 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Decision — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..00215d2 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}New request — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..6254420 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Sign in — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/mail_status.html b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/mail_status.html new file mode 100644 index 0000000..929da67 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/mail_status.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Mail status — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Mail status

+

Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.

+ +{% if not configured %} +
Mail account not configured — MAIL_USER is empty, so messages are printed to the server console (dev mode) instead of being sent.
+{% endif %} + +
+
{{ stats.get('sent', 0) }}Sent
+
{{ stats.get('error', 0) }}Errors
+
{{ copy_fails }}Sent-copy failures
+
{{ stats.get('dev', 0) }}Dev mode
+
+ +
+ + + {% for m in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
WhenToSubjectRequestDeliverySent folder
{{ m.at }}{{ m.recipients }}{{ m.subject }} + {% if m.detail %}
{{ m.detail }}{% endif %}
{% if m.request_id %}#{{ m.request_id }}{% else %}—{% endif %} + {% if m.status == 'sent' %}sent + {% elif m.status == 'error' %}error + {% else %}dev{% endif %} + + {% if m.sent_copy.startswith('saved') %}{{ m.sent_copy }} + {% elif m.sent_copy.startswith('failed') %}copy failed
{{ m.sent_copy }} + {% else %}{{ m.sent_copy }}{% endif %} +
No mail has been sent yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..b6e8c1c --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if attachments %} +

Attachments (included in every approval mail):
+ {% for att in attachments %} + {{ att.name }}{% if not loop.last %} · {% endif %} + {% endfor %} +

+ {% endif %} + {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'denied' and req.deny_reason %} +

Reason for denial:

+
{{ req.deny_reason }}
+ {% endif %} + {% if req.status == 'pending' %} +
+
+ + Same effect as the current approver's email links. +
+
+
+ + + +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+ + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..3f658f9 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Users — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow. Users fill in their own details (name, unit, e-mail, MFA) on their Configure profile page; you can disable MFA here if someone loses their device.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.first_name or u.last_name %}{{ u.first_name }} {{ u.last_name }}{% endif %} + {% if u.unit_location %}{{ u.unit_location }}{% endif %} + {% if u.is_admin %}admin{% endif %} + {% if u.mfa_secret %}MFA on{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.mfa_secret %} +
+ + + +
+ {% endif %} + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..47202ae --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {created_at} {summary} {deny_reason} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+ +
+

Images for email bodies

+

These images can be used in the approval request body, approved notice body and + denied notice body above. Copy an image's tag into the text where the image should appear — + it is embedded in the email at that spot. PNG, JPG or GIF, up to 2 MB each. The library is shared by all workflows.

+ + {% if images %} +
+ {% for name in images %} +
+ {{ name }} +
+ {{ name }} + {{ '{image:' + name + '}' }} +
+ + + +
+
+
+ {% endfor %} +
+ {% else %} +

No images uploaded yet.

+ {% endif %} + +
+ + + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/totp.py b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/totp.py new file mode 100644 index 0000000..909a241 --- /dev/null +++ b/OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/totp.py @@ -0,0 +1,40 @@ +"""Time-based one-time passwords (TOTP, RFC 6238) using only the standard +library. Compatible with Google Authenticator, Authy, 1Password, etc.""" +import base64 +import hashlib +import hmac +import secrets +import struct +import time +from urllib.parse import quote + +ISSUER = "MartinhalApprovalFlow" + + +def new_secret() -> str: + """A new base32 secret to enroll in an authenticator app.""" + return base64.b32encode(secrets.token_bytes(20)).decode() + + +def _code_at(secret: str, counter: int) -> str: + key = base64.b32decode(secret + "=" * (-len(secret) % 8), casefold=True) + digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 15 + number = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000 + return f"{number:06d}" + + +def verify(secret: str, code: str) -> bool: + """Check a 6-digit code, allowing one 30s step of clock drift each way.""" + code = (code or "").strip().replace(" ", "") + if not (secret and code.isdigit() and len(code) == 6): + return False + counter = int(time.time() // 30) + return any(hmac.compare_digest(_code_at(secret, counter + drift), code) + for drift in (-1, 0, 1)) + + +def otpauth_uri(username: str, secret: str) -> str: + """URI encoded as a QR code for authenticator apps.""" + return (f"otpauth://totp/{quote(ISSUER)}:{quote(username)}" + f"?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period=30") diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow.zip b/OLD_VERSIONS/v9.3 patch/approvalflow.zip new file mode 100644 index 0000000..711c1e3 Binary files /dev/null and b/OLD_VERSIONS/v9.3 patch/approvalflow.zip differ diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/.env.example b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/.env.example new file mode 100644 index 0000000..51b61a3 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/.env.example @@ -0,0 +1,18 @@ +# NOTE: Docker Compose treats '$' in this file as variable substitution. +# If any value (e.g. a password) contains a literal '$', write it as '$$': +# MAIL_PASSWORD=xK9$$vh4pQ2 -> the container receives xK9$vh4pQ2 +# Otherwise you'll see a warning like: The "..." variable is not set. + +# --- Web app --- +SECRET_KEY=change-me-to-a-long-random-string +BASE_URL=http://localhost:8000 # public URL used in approve/deny links +PORT=8000 +ADMIN_USERNAME=admin # created on first run only +ADMIN_PASSWORD=changeme + +# --- Purelymail account (only login and password are needed) --- +# Server settings are preconfigured: smtp.purelymail.com, port 465, SSL/TLS. +# If Two Factor Authentication is enabled on the account, use an App Password. +# Leave MAIL_USER empty to print outgoing mail to the console (dev mode). +MAIL_USER=you@yourdomain.com +MAIL_PASSWORD=secret diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/Dockerfile b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/Dockerfile new file mode 100644 index 0000000..0121b92 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +ENV DATABASE_PATH=/data/approvalflow.db +VOLUME /data +EXPOSE 8000 +CMD ["gunicorn", "-b", "0.0.0.0:8000", "--workers", "1", "--threads", "8", "app:app"] diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/README.md b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/README.md new file mode 100644 index 0000000..4883066 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/README.md @@ -0,0 +1,116 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it. + +Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password. + +## What it does + +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Attachments.** A request can include one or more uploaded files (15 MB combined). They are attached to **every approval mail** sent along the chain, and admins can download each from the request page. Files are stored next to the database in `data/attachments/`. +- **Knowledge chain.** Next to each workflow's approval chain there is a knowledge chain: addresses that never approve anything but are informed by e-mail when a request is created and on every step approval, denial, or final approval. They are not counted as "involved" and don't receive the participant outcome notice — only their own FYI messages, which include the request's attached files just like the approval mails. +- **Configure profile.** Every user has a profile page (top-right corner): first name, last name, unit location, e-mail, change password, and MFA. The profile e-mail pre-fills the mandatory "Request's e-mail" field when submitting a request. +- **MFA.** Any user can enable two-factor authentication from their Configure profile page (TOTP — Google Authenticator, Authy, 1Password, …). Sign-in then requires password + 6-digit code. Admins can disable MFA for a user who lost their device. +- **Light and dark mode.** A toggle in the top bar (and on the login page) switches themes; the choice is remembered per browser and defaults to the system preference. +- **Images in emails.** Admins upload PNG/JPG/GIF images (up to 2 MB each) in the library shown below the Email wording section of any workflow. Placing an image's `{image:name}` tag in the approval request body, approved notice body, or denied notice body embeds the image at that spot (HTML email with a plain-text fallback). +- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +> **If a value in `.env` contains a `$`** (common in passwords), escape it as `$$` — e.g. `MAIL_PASSWORD=xK9$$vh4pQ2`. Docker Compose otherwise treats `$word` as a variable and silently replaces it with a blank string, printing a warning like `The "word" variable is not set`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account. + +## Mail server + +The app is preconfigured for Purelymail's servers — you never enter host or port settings: + +| | | +|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS | +| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS | + +Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out. + +Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password. + +## The request lifecycle + +``` +web form (login required) + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{summary}` (creation + each approval with timestamps, for the approved notice), `{deny_reason}`, `{approve_url}`, `{deny_url}`. + +## Upgrading an existing instance (no data loss) + +All schema changes are applied automatically at boot by the built-in migrations — +the SQLite database and uploaded files live in `./data/`, which is a volume and is +never touched by rebuilding the image. + +```bash +cd /path/to/approvalflow +docker compose down # stop the app (data stays in ./data) +cp -r data data.backup-$(date +%F) # safety backup of DB + attachments +# replace the code with the new version, KEEPING .env and data/: +# unzip -o approvalflow.zip -d .. (or copy the new files over the old ones) +docker compose up -d --build # rebuild image, start; migrations run at boot +docker compose logs | grep migrate # optional: see what was migrated +``` + +Roll back = stop, restore `data.backup-…` to `data/`, start the previous image. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit/mail-log helpers +pipeline.py approval chain + decision/notification logic +mailer.py Purelymail SMTP sending, Sent-folder copy, mail log +templates/ pages static/style.css styling +``` diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/app.py b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/app.py new file mode 100644 index 0000000..22bd546 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/app.py @@ -0,0 +1,579 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, send_file, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +import db +import pipeline +import totp +from mailer import send_mail +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) +# 15 MB attachment limit (+ small allowance for the rest of the form) +app.config["MAX_CONTENT_LENGTH"] = db.MAX_ATTACHMENT_BYTES + 512 * 1024 + + +@app.errorhandler(413) +def too_large(e): + flash("The uploaded files are too large — the combined limit is 15 MB.", "error") + return redirect(url_for("index")) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +def _start_session(user, next_url=None): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(next_url or url_for("index")) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + if user["mfa_secret"]: + session.clear() + session["mfa_pending"] = user["id"] + session["mfa_next"] = request.args.get("next") or "" + return redirect(url_for("login_mfa")) + return _start_session(user, request.args.get("next")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/login/mfa", methods=["GET", "POST"]) +def login_mfa(): + uid = session.get("mfa_pending") + if not uid: + return redirect(url_for("login")) + user = g.db.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() + if user is None or not user["mfa_secret"]: + session.clear() + return redirect(url_for("login")) + if request.method == "POST": + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + return _start_session(user, session.get("mfa_next") or None) + flash("Wrong verification code — try again.", "error") + return render_template("login.html", mfa_stage=True, username=user["username"]) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- account (MFA) + +@app.route("/account", methods=["GET", "POST"]) +@login_required +def account(): + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + setup_secret = session.get("mfa_setup") + + if request.method == "POST": + act = request.form.get("action") + if act == "profile": + email = request.form.get("email", "").strip().lower() + if email and "@" not in email: + flash("That e-mail address doesn't look valid.", "error") + else: + g.db.execute( + "UPDATE users SET first_name=?, last_name=?, unit_location=?, email=? WHERE id=?", + (request.form.get("first_name", "").strip(), + request.form.get("last_name", "").strip(), + request.form.get("unit_location", "").strip(), + email, user["id"])) + g.db.commit() + flash("Profile saved.", "ok") + return redirect(url_for("account")) + if act == "password": + if not check_password_hash(user["password_hash"], request.form.get("current_password", "")): + flash("Your current password is wrong.", "error") + elif not request.form.get("new_password"): + flash("The new password can't be empty.", "error") + elif request.form.get("new_password") != request.form.get("confirm_password"): + flash("The new passwords don't match.", "error") + else: + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(request.form["new_password"]), user["id"])) + g.db.commit() + flash("Password changed.", "ok") + return redirect(url_for("account")) + if act == "begin": + session["mfa_setup"] = totp.new_secret() + return redirect(url_for("account")) + if act == "cancel": + session.pop("mfa_setup", None) + return redirect(url_for("account")) + if act == "confirm" and setup_secret: + if totp.verify(setup_secret, request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret=? WHERE id=?", + (setup_secret, user["id"])) + g.db.commit() + session.pop("mfa_setup", None) + flash("Two-factor authentication is now enabled on your account.", "ok") + else: + flash("That code didn't match — scan the QR code and try again.", "error") + return redirect(url_for("account")) + if act == "disable" and user["mfa_secret"]: + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", (user["id"],)) + g.db.commit() + flash("Two-factor authentication disabled.", "ok") + else: + flash("Wrong code — MFA was not disabled.", "error") + return redirect(url_for("account")) + + otpauth = totp.otpauth_uri(user["username"], setup_secret) if setup_secret else "" + return render_template("account.html", user=user, + setup_secret=setup_secret, otpauth=otpauth) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + requester = request.form.get("request_email", "").strip().lower() + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + uploads = [(f, f.read()) for f in request.files.getlist("attachments") + if f and f.filename] + total = sum(len(data) for _, data in uploads) + if not requester or "@" not in requester: + flash("Request's e-mail is required and must be a valid address.", "error") + elif not subject or not body: + flash("Subject and message are both required.", "error") + elif total > db.MAX_ATTACHMENT_BYTES: + flash("The uploaded files are too large — the combined limit is 15 MB.", "error") + else: + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + if uploads: + os.makedirs(db.ATTACH_DIR, exist_ok=True) + entries = [] + for i, (f, data) in enumerate(uploads, 1): + name = secure_filename(f.filename) or f"attachment-{i}" + stored = f"{rid}_{i}_{name}" + with open(db.attachment_path(stored), "wb") as fh: + fh.write(data) + entries.append({"stored": stored, "name": name}) + g.db.execute("UPDATE requests SET attachment=? WHERE id=?", + (json.dumps(entries), rid)) + db.log(g.db, rid, "attachments saved", + ", ".join(f"'{e['name']}'" for e in entries) + + f" ({total} bytes total) — included in every approval mail") + g.db.commit() + pipeline.send_approval_request(rid) + pipeline.notify_knowledge(rid, f"New request created by {requester}") + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15", + (session["username"], (user["email"] or "-").lower())).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests, + user_email=user["email"] or "") + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain, + error="A reason for denial is required."), 400 + status, req2, message = pipeline.decide(token, action, decided_by, reason=reason) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + flash("A reason for denial is required.", "error") + return redirect(url_for("admin_request", rid=rid)) + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason) + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain, + attachments=pipeline.attachment_list(req)) + + +@app.route("/admin/request//attachment/") +@admin_required +def request_attachment(rid, idx): + req = g.db.execute("SELECT attachment FROM requests WHERE id=?", (rid,)).fetchone() + if req is None: + abort(404) + entries = pipeline.attachment_list(req) + if idx < 0 or idx >= len(entries): + abort(404) + path = db.attachment_path(entries[idx]["stored"]) + if not os.path.exists(path): + abort(404) + return send_file(path, as_attachment=True, download_name=entries[idx]["name"]) + + +@app.route("/admin/request//send-log", methods=["POST"]) +@admin_required +def send_request_log(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers, w.knowledge FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + to = request.form.get("email", "").strip() + if not to or "@" not in to: + flash("A valid e-mail address is required to send the log.", "error") + return redirect(url_for("admin_request", rid=rid)) + + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + chain = pipeline.approver_chain(req) + participants = json.loads(req["participants"]) + + lines = [ + f"Full log of request #{req['id']} — {req['subject']}", + "=" * 60, + f"Workflow: {req['wf_name']}", + f"Requester: {req['requester']}", + f"Created: {req['created_at']}", + f"Status: {req['status'].upper()}", + ] + if req["decided_by"]: + lines.append(f"Final decision: by {req['decided_by']} at {req['decided_at']}") + if req["deny_reason"]: + lines.append(f"Denial reason: {req['deny_reason']}") + lines += [ + f"Approval chain: {' -> '.join(chain)}", + f"Knowledge chain: {', '.join(pipeline.knowledge_chain(req)) or '(none)'}", + f"People involved: {', '.join(participants)}", + "", + "Message:", + req["body"], + "", + "Event log:", + "-" * 60, + ] + for l in logs: + lines.append(f"{l['at']} | {l['event']}") + if l["detail"]: + lines.append(f"{'':25} {l['detail']}") + body = "\n".join(lines) + + ok, detail = send_mail(to, f"[{req['wf_name']}] Full log of request #{req['id']}: {req['subject']}", + body, request_id=rid) + db.log(g.db, rid, "log sent" if ok else "mail error", + f"Full log emailed to {to} by {session['username']} — {detail}") + g.db.commit() + flash(f"Full log sent to {to}." if ok else f"Could not send the log: {detail}", + "ok" if ok else "error") + return redirect(url_for("admin_request", rid=rid)) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + knowledge = [a.strip() for a in f.get("knowledge", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, knowledge, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + json.dumps(knowledge), 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?, knowledge=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + json.dumps(knowledge), 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES, + images=_email_images()) + + +# ---------------------------------------------------------------- admin: mail status + +@app.route("/admin/mail") +@admin_required +def mail_status(): + rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall() + stats = {s["status"]: s["c"] for s in + g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")} + copy_fails = g.db.execute( + "SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"] + configured = bool(os.environ.get("MAIL_USER")) + return render_template("mail_status.html", rows=rows, stats=stats, + copy_fails=copy_fails, configured=configured) + + +# ---------------------------------------------------------------- admin: email images + +def _email_images(): + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + return sorted(f for f in os.listdir(db.EMAIL_IMG_DIR) + if os.path.splitext(f)[1].lower() in db.EMAIL_IMAGE_TYPES) + + +@app.route("/admin/email-images/upload", methods=["POST"]) +@admin_required +def email_image_upload(): + nxt = request.form.get("next") or url_for("admin") + upload = request.files.get("image") + if not upload or not upload.filename: + flash("Choose an image file to upload.", "error") + return redirect(nxt) + name = secure_filename(upload.filename) + ext = os.path.splitext(name)[1].lower() + if ext not in db.EMAIL_IMAGE_TYPES: + flash("Only PNG, JPG and GIF images can be used in emails.", "error") + return redirect(nxt) + data = upload.read() + if len(data) > db.MAX_EMAIL_IMAGE_BYTES: + flash("Email images are limited to 2 MB each.", "error") + return redirect(nxt) + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + if os.path.exists(os.path.join(db.EMAIL_IMG_DIR, name)): + flash(f"An image named “{name}” already exists — delete it first or rename the file.", "error") + return redirect(nxt) + with open(os.path.join(db.EMAIL_IMG_DIR, name), "wb") as fh: + fh.write(data) + flash(f"Image “{name}” uploaded — use it with {{image:{name}}} in the email bodies.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/delete", methods=["POST"]) +@admin_required +def email_image_delete(): + nxt = request.form.get("next") or url_for("admin") + name = secure_filename(request.form.get("name", "")) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if name and os.path.isfile(path): + os.remove(path) + flash(f"Image “{name}” deleted.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/") +@admin_required +def email_image(name): + name = secure_filename(name) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if not (name and os.path.isfile(path)): + abort(404) + return send_file(path) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "mfa_off": + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", + (f.get("user_id", type=int),)) + flash("MFA disabled for that user — they can re-enable it from their Account page.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/db.py b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/db.py new file mode 100644 index 0000000..145b578 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/db.py @@ -0,0 +1,249 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) +ATTACH_DIR = os.path.join(os.path.dirname(DB_PATH), "attachments") +EMAIL_IMG_DIR = os.path.join(os.path.dirname(DB_PATH), "email_images") + +MAX_EMAIL_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB per email image +EMAIL_IMAGE_TYPES = {".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg", ".gif": "gif"} + +MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024 # 15 MB + + +def attachment_path(stored_name): + return os.path.join(ATTACH_DIR, stored_name) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + first_name TEXT NOT NULL DEFAULT '', + last_name TEXT NOT NULL DEFAULT '', + unit_location TEXT NOT NULL DEFAULT '', + mfa_secret TEXT NOT NULL DEFAULT '', -- empty = MFA disabled + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + knowledge TEXT NOT NULL DEFAULT '[]', -- JSON list: informed of every event, never approve + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + deny_reason TEXT NOT NULL DEFAULT '', + attachment TEXT NOT NULL DEFAULT '[]', -- JSON list of {"stored": ..., "name": ...} + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL REFERENCES requests(id) ON DELETE CASCADE, + step INTEGER NOT NULL, -- 1-based position in the chain + decided_by TEXT NOT NULL, + decision TEXT NOT NULL, -- approved / denied + at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS mail_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + request_id INTEGER, -- related request, if any + recipients TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, -- sent / error / dev + detail TEXT DEFAULT '', -- SMTP error message, if any + sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Summary:\n{summary}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Reason for denial:\n{deny_reason}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH, timeout=15) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA busy_timeout = 15000") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + if rcols and "deny_reason" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN deny_reason TEXT NOT NULL DEFAULT ''") + print("[migrate] Added deny_reason to requests.") + if rcols and "attachment" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN attachment TEXT NOT NULL DEFAULT ''") + print("[migrate] Added attachment to requests.") + ucols = {r["name"] for r in con.execute("PRAGMA table_info(users)")} + if ucols and "mfa_secret" not in ucols: + con.execute("ALTER TABLE users ADD COLUMN mfa_secret TEXT NOT NULL DEFAULT ''") + print("[migrate] Added mfa_secret to users.") + for col in ("first_name", "last_name", "unit_location"): + if ucols and col not in ucols: + con.execute(f"ALTER TABLE users ADD COLUMN {col} TEXT NOT NULL DEFAULT ''") + print(f"[migrate] Added {col} to users.") + wcols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if wcols and "knowledge" not in wcols: + con.execute("ALTER TABLE workflows ADD COLUMN knowledge TEXT NOT NULL DEFAULT '[]'") + print("[migrate] Added knowledge chain to workflows.") + # single-attachment values -> JSON list format + if rcols and "attachment" in rcols: + for row in con.execute("SELECT id, attachment FROM requests " + "WHERE attachment != '' AND attachment NOT LIKE '[%'").fetchall(): + stored = row["attachment"] + entry = [{"stored": stored, "name": stored.split("_", 1)[-1]}] + con.execute("UPDATE requests SET attachment=? WHERE id=?", + (json.dumps(entry), row["id"])) + print(f"[migrate] Converted attachment of request #{row['id']} to list format.") + con.execute("UPDATE requests SET attachment='[]' WHERE attachment=''") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/docker-compose.yml b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/mailer.py b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/mailer.py new file mode 100644 index 0000000..51c7fc9 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/mailer.py @@ -0,0 +1,145 @@ +"""Outbound email via Purelymail, and template rendering. + +Preconfigured for Purelymail — only the account's login and password are needed: + + MAIL_USER=you@yourdomain.com (or you@purelymail.com) + MAIL_PASSWORD=... (use an App Password if 2FA is enabled) + +Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a +copy is saved to the account's Sent folder over IMAP +(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is +recorded in the mail_log table shown on the admin "Mail status" page. +""" +import html as html_mod +import imaplib +import os +import smtplib +import time +from email.message import EmailMessage +from email.utils import make_msgid + +import db + +SMTP_HOST = "smtp.purelymail.com" +SMTP_PORT = 465 # SSL/TLS +IMAP_HOST = "imap.purelymail.com" +IMAP_PORT = 993 # SSL/TLS + +SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items") + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def _save_to_sent(user, password, msg) -> str: + """Append a copy of the message to the account's Sent folder. Returns a status string.""" + try: + with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap: + imap.login(user, password) + raw = msg.as_bytes() + stamp = imaplib.Time2Internaldate(time.time()) + last_err = "" + for folder in SENT_FOLDER_CANDIDATES: + status, resp = imap.append(folder, r"(\Seen)", stamp, raw) + if status == "OK": + return f"saved to '{folder}'" + last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else "" + # No known folder worked — create "Sent" and try once more. + imap.create("Sent") + status, resp = imap.append("Sent", r"(\Seen)", stamp, raw) + if status == "OK": + return "saved to 'Sent' (folder created)" + return f"failed: {last_err or 'could not append to any Sent folder'}" + except Exception as e: + return f"failed: {e}" + + +def _record(request_id, recipients, subject, status, detail, sent_copy): + try: + con = db.get_db() + con.execute( + "INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)" + " VALUES (?,?,?,?,?,?,?)", + (db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy)) + con.commit() + con.close() + except Exception as e: + print(f"[mail] Could not record mail log entry: {e}") + + +def send_mail(to_addrs, subject, body, request_id=None, attachments=None, inline_images=None): + """Send an email through Purelymail, keep a copy in the Sent folder, and + record the attempt in the mail log. Returns (ok, detail). + + attachments: optional list of (filename, bytes, maintype, subtype). + inline_images: optional dict {marker_name: (bytes, subtype)}. When given, + an HTML alternative is built where each "{image:marker_name}" + in the body becomes an embedded ; the plain-text part + shows "[image: marker_name]" instead. + """ + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + user = os.environ.get("MAIL_USER", "") + password = os.environ.get("MAIL_PASSWORD", "") + + msg = EmailMessage() + msg["From"] = user or "approvalflow@localhost" + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + + if inline_images: + text = body + for name in inline_images: + text = text.replace("{image:" + name + "}", f"[image: {name}]") + msg.set_content(text) + + html = html_mod.escape(body).replace("\n", "
\n") + cids = {} + for name in inline_images: + cid = make_msgid(domain="martinhal.approvalflow") + cids[name] = cid + html = html.replace("{image:" + name + "}", + f'{html_mod.escape(name)}') + msg.add_alternative( + f'
{html}
', + subtype="html") + html_part = msg.get_payload()[-1] + for name, (data, subtype) in inline_images.items(): + html_part.add_related(data, maintype="image", subtype=subtype, cid=cids[name]) + else: + msg.set_content(body) + + for filename, data, maintype, subtype in (attachments or []): + msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) + + if not user: + # Dev mode: print instead of sending so the app is testable without an account. + att = "".join(f"\n[attachment: {f} ({len(b)} bytes)]" for f, b, *_ in (attachments or [])) + img = "".join(f"\n[inline image: {n} ({len(d)} bytes, {s})]" for n, (d, s) in (inline_images or {}).items()) + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}{att}{img}\n{'-'*50}") + _record(request_id, to_addrs, subject, "dev", + "MAIL_USER not set — printed to console", "skipped (dev mode)") + return True, "dev mode (MAIL_USER not set) — printed to console" + + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(user, password) + server.send_message(msg) + except Exception as e: + detail = f"SMTP error: {e}" + _record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)") + return False, detail + + sent_copy = _save_to_sent(user, password, msg) + _record(request_id, to_addrs, subject, "sent", "", sent_copy) + return True, f"sent to {', '.join(to_addrs)}" diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/pipeline.py b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/pipeline.py new file mode 100644 index 0000000..8db535a --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/pipeline.py @@ -0,0 +1,292 @@ +"""Request pipeline for requests created on the site. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import mimetypes +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "created_at": req["created_at"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def _record_decision(con, request_id, step, decided_by, decision): + con.execute("INSERT INTO decisions (request_id, step, decided_by, decision, at) VALUES (?,?,?,?,?)", + (request_id, step, decided_by, decision, db.now())) + + +def build_summary(con, req, total_steps): + """Human-readable history: when the request was created and who + approved (or denied) each step, with timestamps.""" + lines = [f" Created {req['created_at']} by {req['requester']}"] + for d in con.execute("SELECT * FROM decisions WHERE request_id=? ORDER BY id", (req["id"],)): + verb = "Approved" if d["decision"] == "approved" else "DENIED" + lines.append(f" Step {d['step']}/{total_steps} {verb} {d['at']} by {d['decided_by']}") + return "\n".join(lines) + + +def collect_inline_images(rendered_body): + """Find {image:name} markers in a rendered body and load those images + from the email image library. Returns a dict for send_mail, or None.""" + import re + names = set(re.findall(r"\{image:([A-Za-z0-9._\-]+)\}", rendered_body)) + images = {} + for name in names: + ext = os.path.splitext(name)[1].lower() + subtype = db.EMAIL_IMAGE_TYPES.get(ext) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if subtype and os.path.isfile(path): + with open(path, "rb") as f: + images[name] = (f.read(), subtype) + return images or None + + +def attachment_list(req): + """Parse the request's attachment column into [{"stored":…, "name":…}, …].""" + try: + return json.loads(req["attachment"] or "[]") + except Exception: + return [] + + +def load_attachments(req): + """Load every uploaded file of the request as send_mail attachments.""" + out = [] + for entry in attachment_list(req): + path = db.attachment_path(entry["stored"]) + try: + with open(path, "rb") as f: + data = f.read() + except OSError: + continue + ctype = mimetypes.guess_type(entry["name"])[0] or "application/octet-stream" + maintype, subtype = ctype.split("/", 1) + out.append((entry["name"], data, maintype, subtype)) + return out or None + + +def knowledge_chain(wf): + try: + chain = json.loads(wf["knowledge"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def notify_knowledge(request_id, event): + """Inform the workflow's knowledge chain of an event. These addresses are + never part of the approval chain — they only receive FYI messages.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + watchers = knowledge_chain(wf) + if not watchers: + return + chain = approver_chain(wf) + subject = f"[{wf['name']}] FYI — request #{req['id']}: {event}" + body = ( + f"For your information (you are on the knowledge chain of this workflow —\n" + f"no action is needed from you):\n\n" + f"{event}\n\n" + f"Workflow: {wf['name']}\n" + f"From: {req['requester']}\n" + f"Created: {req['created_at']}\n" + f"Subject: {req['subject']}\n" + f"Status: {req['status'].upper()}" + f" (step {min(req['current_step'], max(len(chain)-1, 0)) + 1} of {len(chain)})\n\n" + f"Message:\n{req['body']}\n" + ) + if req["deny_reason"]: + body += f"\nReason for denial:\n{req['deny_reason']}\n" + ok, detail = send_mail(watchers, subject, body, request_id=request_id, + attachments=load_attachments(req)) + db.log(con, request_id, + "knowledge chain informed" if ok else "mail error", + f"'{event}' to {', '.join(watchers)} — {detail}") + con.commit() + finally: + con.close() + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + con.commit() # release any pending writes before slow mail I/O + v = _vars(wf, req) + body_t = wf["approval_body"] + if "{created_at}" not in body_t: # creation time is mandatory in every mail + body_t = "Request created: {created_at}\n\n" + body_t + attachments = load_attachments(req) + rendered_body = render(body_t, **v) + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + rendered_body, + request_id=request_id, + attachments=attachments, + inline_images=collect_inline_images(rendered_body)) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + summary = build_summary(con, req, len(approver_chain(wf))) + con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper(), + summary=summary, deny_reason=req["deny_reason"]) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + # These are mandatory in the outcome notice: append them if the workflow's + # custom template doesn't include the placeholder. + if new_status == "approved" and "{summary}" not in body_t: + body_t += "\n\nSummary:\n{summary}\n" + if new_status == "denied" and "{deny_reason}" not in body_t: + body_t += "\n\nReason for denial:\n{deny_reason}\n" + if "{created_at}" not in body_t and "{summary}" not in body_t: + # creation time is mandatory in every mail ({summary} already includes it) + body_t = "Request created: {created_at}\n\n" + body_t + rendered_body = render(body_t, **v) + ok, detail = send_mail(participants, render(subj_t, **v), rendered_body, + request_id=req["id"], + inline_images=collect_inline_images(rendered_body)) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by, reason=""): + """Handle a decision at the request's current step. + + A denial requires a reason (callers enforce it as a mandatory field; + this is a second line of defense). + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + reason = (reason or "").strip() + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + if not reason: + return "needreason", req, "A reason for denial is required." + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=?, deny_reason=? WHERE id=?", + (decided_by, db.now(), reason, req["id"])) + _record_decision(con, req["id"], step + 1, decided_by, "denied") + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped. Reason: {reason}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + notify_knowledge(req["id"], f"DENIED at step {step + 1}/{len(chain)} by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + _record_decision(con, req["id"], step + 1, decided_by, "approved") + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + con.commit() + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + notify_knowledge(req["id"], + f"FULLY APPROVED — final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + notify_knowledge(req["id"], f"Step {step + 1}/{len(chain)} approved by {decided_by} — " + f"now waiting on {chain[step + 1]}") + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/requirements.txt b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/static/style.css b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/static/style.css new file mode 100644 index 0000000..73489e0 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/static/style.css @@ -0,0 +1,195 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root, [data-theme="light"] { + --paper: #fafbfc; + --card: #ffffff; + --field: #ffffff; + --hover: #f2f6f8; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --flash-ok-bg: #e4f3ee; --flash-ok-line: #bfe0d5; --flash-ok-ink: #0b5c4c; + --flash-err-bg: #f9e8e6; --flash-err-line: #edc7c3; --flash-err-ink: #872a24; + --radius: 8px; + font-size: 16px; +} +[data-theme="dark"] { + --paper: #10161c; + --card: #18212a; + --field: #10161c; + --hover: #1e2a35; + --ink: #e4ebf1; + --ink-soft: #93a5b2; + --line: #2b3945; + --approve: #35b598; + --deny: #e07067; + --pending: #d9a24a; + --focus: #6aa9e0; + --flash-ok-bg: #12352c; --flash-ok-line: #1d5a4a; --flash-ok-ink: #8fd8c3; + --flash-err-bg: #3c1f1c; --flash-err-line: #6b3530; --flash-err-ink: #f0b1aa; +} +[data-theme="dark"] body { color-scheme: dark; } +[data-theme="dark"] button, [data-theme="dark"] .btn { + border-color: var(--line); background: #26323e; +} +[data-theme="dark"] button:hover, [data-theme="dark"] .btn:hover { background: #31404e; } +[data-theme="dark"] .btn.approve { background: #14523f; border-color: #14523f; } +[data-theme="dark"] .btn.deny { background: #79312a; border-color: #79312a; } +[data-theme="dark"] .btn.ghost { background: transparent; color: var(--ink); } +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea, input[type=file] { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: var(--field); +} +input[type=file] { padding: .4rem .5rem; } +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: var(--hover); } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: var(--flash-ok-bg); border: 1px solid var(--flash-ok-line); color: var(--flash-ok-ink); } +.flash.error { background: var(--flash-err-bg); border: 1px solid var(--flash-err-line); color: var(--flash-err-ink); } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: var(--hover); border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} + +/* footer */ +.footer { + max-width: 980px; margin: 0 auto; padding: 1.2rem 1.25rem 2rem; + font-size: .8rem; color: var(--ink-soft); text-align: center; + border-top: 1px solid var(--line); +} + +/* theme toggle */ +.theme-toggle { + margin: 0; padding: .15rem .55rem; font-size: 1rem; line-height: 1; + background: transparent; border: 1px solid #4a5b68; color: #b9c6cf; + border-radius: 999px; cursor: pointer; +} +.theme-toggle:hover { color: #fff; border-color: #7fd6c2; background: transparent; } +.theme-toggle.floating { + position: fixed; top: 1rem; right: 1rem; z-index: 10; + border-color: var(--line); color: var(--ink-soft); +} +.theme-toggle.floating:hover { color: var(--ink); border-color: var(--focus); } + +/* email image library */ +.imggrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; } +.imgcard { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--field); } +.imgcard img { display: block; width: 100%; height: 110px; object-fit: contain; background: #fff; } +.imgmeta { padding: .5rem .6rem; display: flex; flex-direction: column; gap: .35rem; font-size: .78rem; } +.imgmeta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.imgmeta .tag { + background: var(--hover); border: 1px dashed var(--line); border-radius: 5px; + padding: .15rem .4rem; cursor: copy; word-break: break-all; white-space: normal; +} diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/account.html b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/account.html new file mode 100644 index 0000000..90eb590 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/account.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% block title %}Configure profile — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Configure profile

+

Signed in as {{ user.username }}

+ +
+

Profile

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Change password

+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Two-factor authentication (MFA)

+ + {% if user.mfa_secret %} +

enabled

+

Signing in requires your password and a 6-digit code from your authenticator app.

+
+ + + + +
+ + {% elif setup_secret %} +

1 — Scan this QR code with Google Authenticator, Authy, 1Password, or any TOTP app. + If you can't scan, enter the secret manually.

+
+

Secret: {{ setup_secret }}

+

2 — Enter the 6-digit code the app shows to confirm.

+
+ + + +
+
+ + +
+ + + + {% else %} +

disabled

+

Add a second sign-in step: your password plus a 6-digit code from an authenticator app on your phone.

+
+ + +
+ {% endif %} +
+{% endblock %} diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/admin.html b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/admin.html new file mode 100644 index 0000000..d42a305 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/admin.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + + {% else %} + + {% endfor %} +
NameApproval chainKnowledge chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% for a in w.knowledge | fromjson %}{{ a }}{% if not loop.last %}
{% endif %}{% else %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/base.html b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/base.html new file mode 100644 index 0000000..26533d9 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/base.html @@ -0,0 +1,52 @@ + + + + + +{% block title %}MartinhalApprovalFlow{% endblock %} + + + + + + +{% if session.get('user_id') %} +
+ MartinhalApprovalFlow + +
+ + Configure profile + {{ session['username'] }} + Sign out +
+
+{% else %} + +{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+
© 2026 Martinhal IT - Joao Vaz - Version 9.3
+ + diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/decision.html b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/decision.html new file mode 100644 index 0000000..4e54315 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/decision.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Decision — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/index.html b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/index.html new file mode 100644 index 0000000..00215d2 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/index.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}New request — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/login.html b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/login.html new file mode 100644 index 0000000..6254420 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Sign in — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/mail_status.html b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/mail_status.html new file mode 100644 index 0000000..929da67 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/mail_status.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Mail status — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Mail status

+

Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.

+ +{% if not configured %} +
Mail account not configured — MAIL_USER is empty, so messages are printed to the server console (dev mode) instead of being sent.
+{% endif %} + +
+
{{ stats.get('sent', 0) }}Sent
+
{{ stats.get('error', 0) }}Errors
+
{{ copy_fails }}Sent-copy failures
+
{{ stats.get('dev', 0) }}Dev mode
+
+ +
+ + + {% for m in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
WhenToSubjectRequestDeliverySent folder
{{ m.at }}{{ m.recipients }}{{ m.subject }} + {% if m.detail %}
{{ m.detail }}{% endif %}
{% if m.request_id %}#{{ m.request_id }}{% else %}—{% endif %} + {% if m.status == 'sent' %}sent + {% elif m.status == 'error' %}error + {% else %}dev{% endif %} + + {% if m.sent_copy.startswith('saved') %}{{ m.sent_copy }} + {% elif m.sent_copy.startswith('failed') %}copy failed
{{ m.sent_copy }} + {% else %}{{ m.sent_copy }}{% endif %} +
No mail has been sent yet.
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/request_detail.html b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/request_detail.html new file mode 100644 index 0000000..b6e8c1c --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/request_detail.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if attachments %} +

Attachments (included in every approval mail):
+ {% for att in attachments %} + {{ att.name }}{% if not loop.last %} · {% endif %} + {% endfor %} +

+ {% endif %} + {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'denied' and req.deny_reason %} +

Reason for denial:

+
{{ req.deny_reason }}
+ {% endif %} + {% if req.status == 'pending' %} +
+
+ + Same effect as the current approver's email links. +
+
+
+ + + +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+ + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/users.html b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/users.html new file mode 100644 index 0000000..3f658f9 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/users.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Users — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow. Users fill in their own details (name, unit, e-mail, MFA) on their Configure profile page; you can disable MFA here if someone loses their device.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.first_name or u.last_name %}{{ u.first_name }} {{ u.last_name }}{% endif %} + {% if u.unit_location %}{{ u.unit_location }}{% endif %} + {% if u.is_admin %}admin{% endif %} + {% if u.mfa_secret %}MFA on{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.mfa_secret %} +
+ + + +
+ {% endif %} + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/workflow_edit.html b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/workflow_edit.html new file mode 100644 index 0000000..47202ae --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/workflow_edit.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {created_at} {summary} {deny_reason} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+ +
+

Images for email bodies

+

These images can be used in the approval request body, approved notice body and + denied notice body above. Copy an image's tag into the text where the image should appear — + it is embedded in the email at that spot. PNG, JPG or GIF, up to 2 MB each. The library is shared by all workflows.

+ + {% if images %} +
+ {% for name in images %} +
+ {{ name }} +
+ {{ name }} + {{ '{image:' + name + '}' }} +
+ + + +
+
+
+ {% endfor %} +
+ {% else %} +

No images uploaded yet.

+ {% endif %} + +
+ + + +
+
+{% endblock %} diff --git a/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/totp.py b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/totp.py new file mode 100644 index 0000000..909a241 --- /dev/null +++ b/OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/totp.py @@ -0,0 +1,40 @@ +"""Time-based one-time passwords (TOTP, RFC 6238) using only the standard +library. Compatible with Google Authenticator, Authy, 1Password, etc.""" +import base64 +import hashlib +import hmac +import secrets +import struct +import time +from urllib.parse import quote + +ISSUER = "MartinhalApprovalFlow" + + +def new_secret() -> str: + """A new base32 secret to enroll in an authenticator app.""" + return base64.b32encode(secrets.token_bytes(20)).decode() + + +def _code_at(secret: str, counter: int) -> str: + key = base64.b32decode(secret + "=" * (-len(secret) % 8), casefold=True) + digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 15 + number = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000 + return f"{number:06d}" + + +def verify(secret: str, code: str) -> bool: + """Check a 6-digit code, allowing one 30s step of clock drift each way.""" + code = (code or "").strip().replace(" ", "") + if not (secret and code.isdigit() and len(code) == 6): + return False + counter = int(time.time() // 30) + return any(hmac.compare_digest(_code_at(secret, counter + drift), code) + for drift in (-1, 0, 1)) + + +def otpauth_uri(username: str, secret: str) -> str: + """URI encoded as a QR code for authenticator apps.""" + return (f"otpauth://totp/{quote(ISSUER)}:{quote(username)}" + f"?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period=30") diff --git a/README.md b/README.md new file mode 100644 index 0000000..4883066 --- /dev/null +++ b/README.md @@ -0,0 +1,116 @@ +# ApprovalFlow + +A self-hosted approval-workflow app. Requests are created on the site (login required), move through each workflow's ordered chain of approvers by email, and once approved or denied, **everyone involved up to that point** is notified. Every request keeps a full audit log of everything that happened to it. + +Outgoing mail is preconfigured for **Purelymail** — you only provide the account's login and password. + +## What it does + +- **Sequential multi-step approval.** Every workflow has an ordered chain of approvers. The request goes to the first person; each approval sends it on to the next; the last approval completes it. **A denial at any step stops the workflow immediately** — nobody later in the chain is contacted. +- **Decisions notify everyone.** On the final outcome (approved or denied), the requester and every approver involved **up to that point** receive the outcome email. +- **Full log.** Every request records: received, each approval request sent, each step's decision, notifications, and any mail errors — each with a timestamp. +- **Attachments.** A request can include one or more uploaded files (15 MB combined). They are attached to **every approval mail** sent along the chain, and admins can download each from the request page. Files are stored next to the database in `data/attachments/`. +- **Knowledge chain.** Next to each workflow's approval chain there is a knowledge chain: addresses that never approve anything but are informed by e-mail when a request is created and on every step approval, denial, or final approval. They are not counted as "involved" and don't receive the participant outcome notice — only their own FYI messages, which include the request's attached files just like the approval mails. +- **Configure profile.** Every user has a profile page (top-right corner): first name, last name, unit location, e-mail, change password, and MFA. The profile e-mail pre-fills the mandatory "Request's e-mail" field when submitting a request. +- **MFA.** Any user can enable two-factor authentication from their Configure profile page (TOTP — Google Authenticator, Authy, 1Password, …). Sign-in then requires password + 6-digit code. Admins can disable MFA for a user who lost their device. +- **Light and dark mode.** A toggle in the top bar (and on the login page) switches themes; the choice is remembered per browser and defaults to the system preference. +- **Images in emails.** Admins upload PNG/JPG/GIF images (up to 2 MB each) in the library shown below the Email wording section of any workflow. Placing an image's `{image:name}` tag in the approval request body, approved notice body, or denied notice body embeds the image at that spot (HTML email with a plain-text fallback). +- **Mail status page (admin only).** Every outgoing email is recorded with its delivery result and whether the Sent-folder copy succeeded, so you can confirm mail is going out and spot errors at a glance. +- **Multiple named workflows**, each personalized: its own approval chain and the wording of all three emails (approval request, approved notice, denied notice) with `{placeholders}`. +- **Main page (login required):** users pick a workflow they have access to and submit a request from the browser. +- **Admin pages:** create/edit/pause workflows, monitor all requests and open their full logs, decide directly from the UI, manage users, and set which users can access which workflows. + +## Quick start (Docker) + +```bash +cp .env.example .env # edit it: SECRET_KEY, BASE_URL, MAIL_USER, MAIL_PASSWORD +docker compose up -d +``` + +Open http://localhost:8000 and sign in with `ADMIN_USERNAME` / `ADMIN_PASSWORD` from `.env` (defaults `admin` / `changeme` — change them). The SQLite database lives in `./data/`. + +> **If a value in `.env` contains a `$`** (common in passwords), escape it as `$$` — e.g. `MAIL_PASSWORD=xK9$$vh4pQ2`. Docker Compose otherwise treats `$word` as a variable and silently replaces it with a blank string, printing a warning like `The "word" variable is not set`. + +## Quick start (bare Python) + +```bash +pip install -r requirements.txt +cp .env.example .env +set -a; source .env; set +a # or export the variables another way +python app.py +``` + +Leaving `MAIL_USER` empty prints outgoing mail to the console instead of sending it — handy for trying the app before entering the account. + +## Mail server + +The app is preconfigured for Purelymail's servers — you never enter host or port settings: + +| | | +|---|---| +| Outgoing (SMTP) | `smtp.purelymail.com`, port `465`, SSL/TLS | +| Sent-folder copy (IMAP) | `imap.purelymail.com`, port `993`, SSL/TLS | + +Every email the app sends is also saved as a copy to the account's **Sent** folder over IMAP, so the mailbox keeps a complete record of what went out. + +Only two settings are asked for in `.env`: `MAIL_USER` (the email address) and `MAIL_PASSWORD`. If the account uses Two Factor Authentication, use an **App Password** instead of the real password. + +## The request lifecycle + +``` +web form (login required) + │ received + logged + ▼ + approval request → approver 1 ── deny ──► STOP: DENIED notice to + │ approve everyone involved so far + ▼ + approval request → approver 2 ── deny ──► STOP: DENIED notice ... + │ approve + ▼ + ⋮ (… every approver in the chain, in order) + ▼ + last approver approves ──► APPROVED notice to everyone involved + (requester + all approvers) +``` + +Each step gets a fresh secret link, so an earlier approver's link cannot decide later steps. + +## Personalization placeholders + +Usable in every workflow's subject and body templates: +`{workflow}`, `{requester}`, `{subject}`, `{body}`, `{request_id}`, `{step}`, `{total_steps}`, `{decided_by}`, `{summary}` (creation + each approval with timestamps, for the approved notice), `{deny_reason}`, `{approve_url}`, `{deny_url}`. + +## Upgrading an existing instance (no data loss) + +All schema changes are applied automatically at boot by the built-in migrations — +the SQLite database and uploaded files live in `./data/`, which is a volume and is +never touched by rebuilding the image. + +```bash +cd /path/to/approvalflow +docker compose down # stop the app (data stays in ./data) +cp -r data data.backup-$(date +%F) # safety backup of DB + attachments +# replace the code with the new version, KEEPING .env and data/: +# unzip -o approvalflow.zip -d .. (or copy the new files over the old ones) +docker compose up -d --build # rebuild image, start; migrations run at boot +docker compose logs | grep migrate # optional: see what was migrated +``` + +Roll back = stop, restore `data.backup-…` to `data/`, start the previous image. + +## Security notes + +- Set a strong `SECRET_KEY` and put the app behind HTTPS (a reverse proxy such as Caddy or nginx) — the approve/deny links are secrets. +- Decision links are unguessable random tokens and work only once; a second click shows "already decided." +- Passwords are stored hashed (Werkzeug PBKDF2). Change the bootstrap admin password immediately. +- Non-admin users only ever see and use the workflows they've been granted. + +## Project layout + +``` +app.py Flask routes, auth, admin pages +db.py SQLite schema + audit/mail-log helpers +pipeline.py approval chain + decision/notification logic +mailer.py Purelymail SMTP sending, Sent-folder copy, mail log +templates/ pages static/style.css styling +``` diff --git a/app.py b/app.py new file mode 100644 index 0000000..22bd546 --- /dev/null +++ b/app.py @@ -0,0 +1,579 @@ +"""ApprovalFlow — self-hosted email approval workflows.""" +import functools +import json +import os +import re +import secrets + +from flask import (Flask, abort, flash, g, redirect, render_template, + request, send_file, session, url_for) +from werkzeug.security import check_password_hash, generate_password_hash +from werkzeug.utils import secure_filename + +import db +import pipeline +import totp +from mailer import send_mail +from db import DEFAULT_TEMPLATES + +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) +# 15 MB attachment limit (+ small allowance for the rest of the form) +app.config["MAX_CONTENT_LENGTH"] = db.MAX_ATTACHMENT_BYTES + 512 * 1024 + + +@app.errorhandler(413) +def too_large(e): + flash("The uploaded files are too large — the combined limit is 15 MB.", "error") + return redirect(url_for("index")) + + +# ---------------------------------------------------------------- helpers + +@app.template_filter("fromjson") +def fromjson_filter(s): + try: + return json.loads(s or "[]") + except Exception: + return [] + + +def slugify(name): + s = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return s or "workflow" + + +def login_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + return view(*a, **kw) + return wrapped + + +def admin_required(view): + @functools.wraps(view) + def wrapped(*a, **kw): + if "user_id" not in session: + return redirect(url_for("login", next=request.path)) + if not session.get("is_admin"): + abort(403) + return view(*a, **kw) + return wrapped + + +@app.before_request +def open_db(): + g.db = db.get_db() + + +@app.teardown_request +def close_db(exc): + d = g.pop("db", None) + if d: + d.close() + + +def user_workflows(user_id): + if session.get("is_admin"): + return g.db.execute("SELECT * FROM workflows WHERE active=1 ORDER BY name").fetchall() + return g.db.execute( + "SELECT w.* FROM workflows w JOIN user_workflows uw ON uw.workflow_id=w.id " + "WHERE uw.user_id=? AND w.active=1 ORDER BY w.name", (user_id,)).fetchall() + + +# ---------------------------------------------------------------- auth + +def _start_session(user, next_url=None): + session.clear() + session["user_id"] = user["id"] + session["username"] = user["username"] + session["is_admin"] = bool(user["is_admin"]) + return redirect(next_url or url_for("index")) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + user = g.db.execute("SELECT * FROM users WHERE username=?", + (request.form.get("username", "").strip(),)).fetchone() + if user and check_password_hash(user["password_hash"], request.form.get("password", "")): + if user["mfa_secret"]: + session.clear() + session["mfa_pending"] = user["id"] + session["mfa_next"] = request.args.get("next") or "" + return redirect(url_for("login_mfa")) + return _start_session(user, request.args.get("next")) + flash("Wrong username or password.", "error") + return render_template("login.html") + + +@app.route("/login/mfa", methods=["GET", "POST"]) +def login_mfa(): + uid = session.get("mfa_pending") + if not uid: + return redirect(url_for("login")) + user = g.db.execute("SELECT * FROM users WHERE id=?", (uid,)).fetchone() + if user is None or not user["mfa_secret"]: + session.clear() + return redirect(url_for("login")) + if request.method == "POST": + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + return _start_session(user, session.get("mfa_next") or None) + flash("Wrong verification code — try again.", "error") + return render_template("login.html", mfa_stage=True, username=user["username"]) + + +@app.route("/logout") +def logout(): + session.clear() + return redirect(url_for("login")) + + +# ---------------------------------------------------------------- account (MFA) + +@app.route("/account", methods=["GET", "POST"]) +@login_required +def account(): + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + setup_secret = session.get("mfa_setup") + + if request.method == "POST": + act = request.form.get("action") + if act == "profile": + email = request.form.get("email", "").strip().lower() + if email and "@" not in email: + flash("That e-mail address doesn't look valid.", "error") + else: + g.db.execute( + "UPDATE users SET first_name=?, last_name=?, unit_location=?, email=? WHERE id=?", + (request.form.get("first_name", "").strip(), + request.form.get("last_name", "").strip(), + request.form.get("unit_location", "").strip(), + email, user["id"])) + g.db.commit() + flash("Profile saved.", "ok") + return redirect(url_for("account")) + if act == "password": + if not check_password_hash(user["password_hash"], request.form.get("current_password", "")): + flash("Your current password is wrong.", "error") + elif not request.form.get("new_password"): + flash("The new password can't be empty.", "error") + elif request.form.get("new_password") != request.form.get("confirm_password"): + flash("The new passwords don't match.", "error") + else: + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(request.form["new_password"]), user["id"])) + g.db.commit() + flash("Password changed.", "ok") + return redirect(url_for("account")) + if act == "begin": + session["mfa_setup"] = totp.new_secret() + return redirect(url_for("account")) + if act == "cancel": + session.pop("mfa_setup", None) + return redirect(url_for("account")) + if act == "confirm" and setup_secret: + if totp.verify(setup_secret, request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret=? WHERE id=?", + (setup_secret, user["id"])) + g.db.commit() + session.pop("mfa_setup", None) + flash("Two-factor authentication is now enabled on your account.", "ok") + else: + flash("That code didn't match — scan the QR code and try again.", "error") + return redirect(url_for("account")) + if act == "disable" and user["mfa_secret"]: + if totp.verify(user["mfa_secret"], request.form.get("code", "")): + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", (user["id"],)) + g.db.commit() + flash("Two-factor authentication disabled.", "ok") + else: + flash("Wrong code — MFA was not disabled.", "error") + return redirect(url_for("account")) + + otpauth = totp.otpauth_uri(user["username"], setup_secret) if setup_secret else "" + return render_template("account.html", user=user, + setup_secret=setup_secret, otpauth=otpauth) + + +# ---------------------------------------------------------------- main page + +@app.route("/", methods=["GET", "POST"]) +@login_required +def index(): + workflows = user_workflows(session["user_id"]) + + user = g.db.execute("SELECT * FROM users WHERE id=?", (session["user_id"],)).fetchone() + + if request.method == "POST": + wf_id = request.form.get("workflow_id", type=int) + wf = next((w for w in workflows if w["id"] == wf_id), None) + if wf is None: + abort(403) # not allowed to use this workflow + requester = request.form.get("request_email", "").strip().lower() + subject = request.form.get("subject", "").strip() + body = request.form.get("body", "").strip() + uploads = [(f, f.read()) for f in request.files.getlist("attachments") + if f and f.filename] + total = sum(len(data) for _, data in uploads) + if not requester or "@" not in requester: + flash("Request's e-mail is required and must be a valid address.", "error") + elif not subject or not body: + flash("Subject and message are both required.", "error") + elif total > db.MAX_ATTACHMENT_BYTES: + flash("The uploaded files are too large — the combined limit is 15 MB.", "error") + else: + rid, _ = db.create_request(g.db, wf["id"], "web", requester, subject, body) + db.log(g.db, rid, "matched", f"Submitted on the web form by {session['username']}") + if uploads: + os.makedirs(db.ATTACH_DIR, exist_ok=True) + entries = [] + for i, (f, data) in enumerate(uploads, 1): + name = secure_filename(f.filename) or f"attachment-{i}" + stored = f"{rid}_{i}_{name}" + with open(db.attachment_path(stored), "wb") as fh: + fh.write(data) + entries.append({"stored": stored, "name": name}) + g.db.execute("UPDATE requests SET attachment=? WHERE id=?", + (json.dumps(entries), rid)) + db.log(g.db, rid, "attachments saved", + ", ".join(f"'{e['name']}'" for e in entries) + + f" ({total} bytes total) — included in every approval mail") + g.db.commit() + pipeline.send_approval_request(rid) + pipeline.notify_knowledge(rid, f"New request created by {requester}") + flash(f"Request #{rid} sent to the approver of “{wf['name']}”.", "ok") + return redirect(url_for("index")) + + my_requests = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "WHERE r.requester=? OR r.requester=? ORDER BY r.id DESC LIMIT 15", + (session["username"], (user["email"] or "-").lower())).fetchall() + return render_template("index.html", workflows=workflows, my_requests=my_requests, + user_email=user["email"] or "") + + +# ---------------------------------------------------------------- decision links (from email) + +@app.route("/decision//", methods=["GET", "POST"]) +def decision(token, action): + if action not in ("approve", "deny"): + abort(404) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.token=?", (token,)).fetchone() + if req is None: + return render_template("decision.html", stage="notfound"), 404 + + chain = pipeline.approver_chain(req) + approver = chain[min(req["current_step"], len(chain) - 1)] if chain else "" + if request.method == "POST": + decided_by = request.form.get("decided_by", "").strip() or approver + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain, + error="A reason for denial is required."), 400 + status, req2, message = pipeline.decide(token, action, decided_by, reason=reason) + return render_template("decision.html", stage="done", req=req2, message=message, + action=action, status=status) + return render_template("decision.html", stage="confirm", req=req, action=action, + approver=approver, chain=chain) + + +# ---------------------------------------------------------------- admin: dashboard & requests + +@app.route("/admin") +@admin_required +def admin(): + rows = g.db.execute( + "SELECT r.*, w.name wf_name FROM requests r JOIN workflows w ON w.id=r.workflow_id " + "ORDER BY r.id DESC LIMIT 200").fetchall() + stats = g.db.execute( + "SELECT status, COUNT(*) c FROM requests GROUP BY status").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + return render_template("admin.html", rows=rows, workflows=workflows, + stats={s["status"]: s["c"] for s in stats}) + + +@app.route("/admin/request/", methods=["GET", "POST"]) +@admin_required +def admin_request(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + if request.method == "POST" and req["status"] == "pending": + action = request.form.get("action") + reason = request.form.get("reason", "").strip() + if action == "deny" and not reason: + flash("A reason for denial is required.", "error") + return redirect(url_for("admin_request", rid=rid)) + if action in ("approve", "deny"): + pipeline.decide(req["token"], action, f"{session['username']} (admin)", reason=reason) + return redirect(url_for("admin_request", rid=rid)) + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + participants = json.loads(req["participants"]) + chain = pipeline.approver_chain(req) + return render_template("request_detail.html", req=req, logs=logs, + participants=participants, chain=chain, + attachments=pipeline.attachment_list(req)) + + +@app.route("/admin/request//attachment/") +@admin_required +def request_attachment(rid, idx): + req = g.db.execute("SELECT attachment FROM requests WHERE id=?", (rid,)).fetchone() + if req is None: + abort(404) + entries = pipeline.attachment_list(req) + if idx < 0 or idx >= len(entries): + abort(404) + path = db.attachment_path(entries[idx]["stored"]) + if not os.path.exists(path): + abort(404) + return send_file(path, as_attachment=True, download_name=entries[idx]["name"]) + + +@app.route("/admin/request//send-log", methods=["POST"]) +@admin_required +def send_request_log(rid): + req = g.db.execute("SELECT r.*, w.name wf_name, w.approvers, w.knowledge FROM requests r " + "JOIN workflows w ON w.id=r.workflow_id WHERE r.id=?", (rid,)).fetchone() + if req is None: + abort(404) + to = request.form.get("email", "").strip() + if not to or "@" not in to: + flash("A valid e-mail address is required to send the log.", "error") + return redirect(url_for("admin_request", rid=rid)) + + logs = g.db.execute("SELECT * FROM request_log WHERE request_id=? ORDER BY id", (rid,)).fetchall() + chain = pipeline.approver_chain(req) + participants = json.loads(req["participants"]) + + lines = [ + f"Full log of request #{req['id']} — {req['subject']}", + "=" * 60, + f"Workflow: {req['wf_name']}", + f"Requester: {req['requester']}", + f"Created: {req['created_at']}", + f"Status: {req['status'].upper()}", + ] + if req["decided_by"]: + lines.append(f"Final decision: by {req['decided_by']} at {req['decided_at']}") + if req["deny_reason"]: + lines.append(f"Denial reason: {req['deny_reason']}") + lines += [ + f"Approval chain: {' -> '.join(chain)}", + f"Knowledge chain: {', '.join(pipeline.knowledge_chain(req)) or '(none)'}", + f"People involved: {', '.join(participants)}", + "", + "Message:", + req["body"], + "", + "Event log:", + "-" * 60, + ] + for l in logs: + lines.append(f"{l['at']} | {l['event']}") + if l["detail"]: + lines.append(f"{'':25} {l['detail']}") + body = "\n".join(lines) + + ok, detail = send_mail(to, f"[{req['wf_name']}] Full log of request #{req['id']}: {req['subject']}", + body, request_id=rid) + db.log(g.db, rid, "log sent" if ok else "mail error", + f"Full log emailed to {to} by {session['username']} — {detail}") + g.db.commit() + flash(f"Full log sent to {to}." if ok else f"Could not send the log: {detail}", + "ok" if ok else "error") + return redirect(url_for("admin_request", rid=rid)) + + +# ---------------------------------------------------------------- admin: workflows + +WF_FIELDS = ("approval_subject", "approval_body", "approved_subject", + "approved_body", "denied_subject", "denied_body") + + +@app.route("/admin/workflows/new", methods=["GET", "POST"]) +@app.route("/admin/workflows/", methods=["GET", "POST"]) +@admin_required +def workflow_edit(wid=None): + wf = g.db.execute("SELECT * FROM workflows WHERE id=?", (wid,)).fetchone() if wid else None + if wid and wf is None: + abort(404) + + if request.method == "POST": + f = request.form + name = f.get("name", "").strip() + approvers = [a.strip() for a in f.get("approvers", "").splitlines() if a.strip()] + knowledge = [a.strip() for a in f.get("knowledge", "").splitlines() if a.strip()] + if not name or not approvers: + flash("A name and at least one approver are required.", "error") + else: + templates = {k: f.get(k, "").strip() or DEFAULT_TEMPLATES[k] for k in WF_FIELDS} + try: + if wf is None: + g.db.execute( + "INSERT INTO workflows (name, slug, description, approvers, knowledge, active," + " approval_subject, approval_body, approved_subject, approved_body," + " denied_subject, denied_body, created_at)" + " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + json.dumps(knowledge), 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], db.now())) + flash(f"Workflow “{name}” created with {len(approvers)} approval step(s).", "ok") + else: + g.db.execute( + "UPDATE workflows SET name=?, slug=?, description=?, approvers=?, knowledge=?," + " active=?, approval_subject=?, approval_body=?," + " approved_subject=?, approved_body=?, denied_subject=?, denied_body=? WHERE id=?", + (name, slugify(name), f.get("description", "").strip(), json.dumps(approvers), + json.dumps(knowledge), 1 if f.get("active") else 0, + *[templates[k] for k in WF_FIELDS], wid)) + flash(f"Workflow “{name}” saved.", "ok") + g.db.commit() + return redirect(url_for("admin")) + except Exception as e: + flash(f"Could not save: {e}", "error") + return render_template("workflow_edit.html", wf=wf, defaults=DEFAULT_TEMPLATES, + images=_email_images()) + + +# ---------------------------------------------------------------- admin: mail status + +@app.route("/admin/mail") +@admin_required +def mail_status(): + rows = g.db.execute("SELECT * FROM mail_log ORDER BY id DESC LIMIT 300").fetchall() + stats = {s["status"]: s["c"] for s in + g.db.execute("SELECT status, COUNT(*) c FROM mail_log GROUP BY status")} + copy_fails = g.db.execute( + "SELECT COUNT(*) c FROM mail_log WHERE sent_copy LIKE 'failed%'").fetchone()["c"] + configured = bool(os.environ.get("MAIL_USER")) + return render_template("mail_status.html", rows=rows, stats=stats, + copy_fails=copy_fails, configured=configured) + + +# ---------------------------------------------------------------- admin: email images + +def _email_images(): + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + return sorted(f for f in os.listdir(db.EMAIL_IMG_DIR) + if os.path.splitext(f)[1].lower() in db.EMAIL_IMAGE_TYPES) + + +@app.route("/admin/email-images/upload", methods=["POST"]) +@admin_required +def email_image_upload(): + nxt = request.form.get("next") or url_for("admin") + upload = request.files.get("image") + if not upload or not upload.filename: + flash("Choose an image file to upload.", "error") + return redirect(nxt) + name = secure_filename(upload.filename) + ext = os.path.splitext(name)[1].lower() + if ext not in db.EMAIL_IMAGE_TYPES: + flash("Only PNG, JPG and GIF images can be used in emails.", "error") + return redirect(nxt) + data = upload.read() + if len(data) > db.MAX_EMAIL_IMAGE_BYTES: + flash("Email images are limited to 2 MB each.", "error") + return redirect(nxt) + os.makedirs(db.EMAIL_IMG_DIR, exist_ok=True) + if os.path.exists(os.path.join(db.EMAIL_IMG_DIR, name)): + flash(f"An image named “{name}” already exists — delete it first or rename the file.", "error") + return redirect(nxt) + with open(os.path.join(db.EMAIL_IMG_DIR, name), "wb") as fh: + fh.write(data) + flash(f"Image “{name}” uploaded — use it with {{image:{name}}} in the email bodies.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/delete", methods=["POST"]) +@admin_required +def email_image_delete(): + nxt = request.form.get("next") or url_for("admin") + name = secure_filename(request.form.get("name", "")) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if name and os.path.isfile(path): + os.remove(path) + flash(f"Image “{name}” deleted.", "ok") + return redirect(nxt) + + +@app.route("/admin/email-images/") +@admin_required +def email_image(name): + name = secure_filename(name) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if not (name and os.path.isfile(path)): + abort(404) + return send_file(path) + + +# ---------------------------------------------------------------- admin: users & access + +@app.route("/admin/users", methods=["GET", "POST"]) +@admin_required +def users(): + if request.method == "POST": + f = request.form + act = f.get("action") + try: + if act == "create": + if not f.get("username") or not f.get("password"): + flash("Username and password are required.", "error") + else: + g.db.execute( + "INSERT INTO users (username, password_hash, email, is_admin, created_at) VALUES (?,?,?,?,?)", + (f["username"].strip(), generate_password_hash(f["password"]), + f.get("email", "").strip(), 1 if f.get("is_admin") else 0, db.now())) + flash(f"User “{f['username']}” created.", "ok") + elif act == "delete": + uid = f.get("user_id", type=int) + if uid == session["user_id"]: + flash("You can't delete your own account.", "error") + else: + g.db.execute("DELETE FROM users WHERE id=?", (uid,)) + flash("User deleted.", "ok") + elif act == "mfa_off": + g.db.execute("UPDATE users SET mfa_secret='' WHERE id=?", + (f.get("user_id", type=int),)) + flash("MFA disabled for that user — they can re-enable it from their Account page.", "ok") + elif act == "password": + g.db.execute("UPDATE users SET password_hash=? WHERE id=?", + (generate_password_hash(f["password"]), f.get("user_id", type=int))) + flash("Password updated.", "ok") + elif act == "access": + uid = f.get("user_id", type=int) + g.db.execute("DELETE FROM user_workflows WHERE user_id=?", (uid,)) + for wid in request.form.getlist("workflow_ids", type=int): + g.db.execute("INSERT OR IGNORE INTO user_workflows (user_id, workflow_id) VALUES (?,?)", + (uid, wid)) + flash("Access updated.", "ok") + g.db.commit() + except Exception as e: + flash(f"Error: {e}", "error") + return redirect(url_for("users")) + + all_users = g.db.execute("SELECT * FROM users ORDER BY username").fetchall() + workflows = g.db.execute("SELECT * FROM workflows ORDER BY name").fetchall() + access = {} + for row in g.db.execute("SELECT * FROM user_workflows"): + access.setdefault(row["user_id"], set()).add(row["workflow_id"]) + return render_template("users.html", users=all_users, workflows=workflows, access=access) + + +# ---------------------------------------------------------------- boot + +db.init_db() + +if __name__ == "__main__": + app.run(host=os.environ.get("HOST", "0.0.0.0"), + port=int(os.environ.get("PORT", "8000")), + debug=os.environ.get("FLASK_DEBUG") == "1") diff --git a/db.py b/db.py new file mode 100644 index 0000000..145b578 --- /dev/null +++ b/db.py @@ -0,0 +1,249 @@ +"""SQLite data layer for ApprovalFlow.""" +import json +import os +import secrets +import sqlite3 +from datetime import datetime, timezone + +from werkzeug.security import generate_password_hash + +DB_PATH = os.environ.get("DATABASE_PATH", os.path.join(os.path.dirname(__file__), "data", "approvalflow.db")) +ATTACH_DIR = os.path.join(os.path.dirname(DB_PATH), "attachments") +EMAIL_IMG_DIR = os.path.join(os.path.dirname(DB_PATH), "email_images") + +MAX_EMAIL_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB per email image +EMAIL_IMAGE_TYPES = {".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg", ".gif": "gif"} + +MAX_ATTACHMENT_BYTES = 15 * 1024 * 1024 # 15 MB + + +def attachment_path(stored_name): + return os.path.join(ATTACH_DIR, stored_name) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + email TEXT DEFAULT '', + first_name TEXT NOT NULL DEFAULT '', + last_name TEXT NOT NULL DEFAULT '', + unit_location TEXT NOT NULL DEFAULT '', + mfa_secret TEXT NOT NULL DEFAULT '', -- empty = MFA disabled + is_admin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS workflows ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + slug TEXT UNIQUE NOT NULL, + description TEXT DEFAULT '', + approvers TEXT NOT NULL DEFAULT '[]', -- JSON list, in approval order + knowledge TEXT NOT NULL DEFAULT '[]', -- JSON list: informed of every event, never approve + active INTEGER NOT NULL DEFAULT 1, + -- personalization (templates support {placeholders}) + approval_subject TEXT NOT NULL, + approval_body TEXT NOT NULL, + approved_subject TEXT NOT NULL, + approved_body TEXT NOT NULL, + denied_subject TEXT NOT NULL, + denied_body TEXT NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_workflows ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + workflow_id INTEGER NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, workflow_id) +); + +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workflow_id INTEGER NOT NULL REFERENCES workflows(id), + source TEXT NOT NULL DEFAULT 'web', -- requests are created on the site + requester TEXT NOT NULL, -- email address (or username for web) + subject TEXT NOT NULL, + body TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', -- JSON list of everyone involved so far + status TEXT NOT NULL DEFAULT 'pending', -- pending / approved / denied + current_step INTEGER NOT NULL DEFAULT 0, -- index into the workflow's approver chain + token TEXT UNIQUE NOT NULL, -- secret for the CURRENT step's approve/deny links + decided_by TEXT DEFAULT '', + deny_reason TEXT NOT NULL DEFAULT '', + attachment TEXT NOT NULL DEFAULT '[]', -- JSON list of {"stored": ..., "name": ...} + created_at TEXT NOT NULL, + decided_at TEXT +); + +CREATE TABLE IF NOT EXISTS decisions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER NOT NULL REFERENCES requests(id) ON DELETE CASCADE, + step INTEGER NOT NULL, -- 1-based position in the chain + decided_by TEXT NOT NULL, + decision TEXT NOT NULL, -- approved / denied + at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS request_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + request_id INTEGER REFERENCES requests(id) ON DELETE CASCADE, + at TEXT NOT NULL, + event TEXT NOT NULL, + detail TEXT DEFAULT '' +); + +CREATE TABLE IF NOT EXISTS mail_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + at TEXT NOT NULL, + request_id INTEGER, -- related request, if any + recipients TEXT NOT NULL, + subject TEXT NOT NULL, + status TEXT NOT NULL, -- sent / error / dev + detail TEXT DEFAULT '', -- SMTP error message, if any + sent_copy TEXT DEFAULT '' -- result of saving to the Sent folder +); +""" + +DEFAULT_TEMPLATES = { + "approval_subject": "[{workflow}] Approval needed ({step}/{total_steps}): {subject}", + "approval_body": ( + "A request needs your decision (approval step {step} of {total_steps}).\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Approve: {approve_url}\n" + "Deny: {deny_url}\n\n" + "If you approve, the request moves on to the next approver.\n" + "If you deny, the workflow stops and everyone involved is notified.\n" + ), + "approved_subject": "[{workflow}] Approved: {subject}", + "approved_body": ( + "The following request was APPROVED.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Summary:\n{summary}\n" + ), + "denied_subject": "[{workflow}] Denied: {subject}", + "denied_body": ( + "The following request was DENIED by {decided_by}.\n\n" + "Workflow: {workflow}\n" + "From: {requester}\n" + "Created: {created_at}\n" + "Subject: {subject}\n\n" + "Message:\n{body}\n\n" + "Reason for denial:\n{deny_reason}\n" + ), +} + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def get_db(): + os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) + con = sqlite3.connect(DB_PATH, timeout=15) + con.row_factory = sqlite3.Row + con.execute("PRAGMA foreign_keys = ON") + con.execute("PRAGMA journal_mode = WAL") + con.execute("PRAGMA busy_timeout = 15000") + return con + + +def _migrate(con): + """Upgrade a database created by the single-approver version, if present.""" + cols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if "approver_email" in cols and "approvers" not in cols: + con.execute("ALTER TABLE workflows ADD COLUMN approvers TEXT NOT NULL DEFAULT '[]'") + for w in con.execute("SELECT id, approver_email FROM workflows").fetchall(): + con.execute("UPDATE workflows SET approvers=? WHERE id=?", + (json.dumps([w["approver_email"]]), w["id"])) + print("[migrate] Converted single approver_email to approver chains.") + rcols = {r["name"] for r in con.execute("PRAGMA table_info(requests)")} + if rcols and "current_step" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN current_step INTEGER NOT NULL DEFAULT 0") + print("[migrate] Added current_step to requests.") + if rcols and "deny_reason" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN deny_reason TEXT NOT NULL DEFAULT ''") + print("[migrate] Added deny_reason to requests.") + if rcols and "attachment" not in rcols: + con.execute("ALTER TABLE requests ADD COLUMN attachment TEXT NOT NULL DEFAULT ''") + print("[migrate] Added attachment to requests.") + ucols = {r["name"] for r in con.execute("PRAGMA table_info(users)")} + if ucols and "mfa_secret" not in ucols: + con.execute("ALTER TABLE users ADD COLUMN mfa_secret TEXT NOT NULL DEFAULT ''") + print("[migrate] Added mfa_secret to users.") + for col in ("first_name", "last_name", "unit_location"): + if ucols and col not in ucols: + con.execute(f"ALTER TABLE users ADD COLUMN {col} TEXT NOT NULL DEFAULT ''") + print(f"[migrate] Added {col} to users.") + wcols = {r["name"] for r in con.execute("PRAGMA table_info(workflows)")} + if wcols and "knowledge" not in wcols: + con.execute("ALTER TABLE workflows ADD COLUMN knowledge TEXT NOT NULL DEFAULT '[]'") + print("[migrate] Added knowledge chain to workflows.") + # single-attachment values -> JSON list format + if rcols and "attachment" in rcols: + for row in con.execute("SELECT id, attachment FROM requests " + "WHERE attachment != '' AND attachment NOT LIKE '[%'").fetchall(): + stored = row["attachment"] + entry = [{"stored": stored, "name": stored.split("_", 1)[-1]}] + con.execute("UPDATE requests SET attachment=? WHERE id=?", + (json.dumps(entry), row["id"])) + print(f"[migrate] Converted attachment of request #{row['id']} to list format.") + con.execute("UPDATE requests SET attachment='[]' WHERE attachment=''") + con.commit() + + +def init_db(): + con = get_db() + _migrate(con) + con.executescript(SCHEMA) + # bootstrap admin user on first run + if con.execute("SELECT COUNT(*) c FROM users").fetchone()["c"] == 0: + username = os.environ.get("ADMIN_USERNAME", "admin") + password = os.environ.get("ADMIN_PASSWORD", "changeme") + con.execute( + "INSERT INTO users (username, password_hash, is_admin, created_at) VALUES (?,?,1,?)", + (username, generate_password_hash(password), now()), + ) + print(f"[init] Created admin user '{username}'. Change the password immediately.") + con.commit() + con.close() + + +def log(con, request_id, event, detail=""): + con.execute( + "INSERT INTO request_log (request_id, at, event, detail) VALUES (?,?,?,?)", + (request_id, now(), event, detail), + ) + + +def add_participants(con, request_id, emails): + """Add people to the 'involved so far' list of a request.""" + row = con.execute("SELECT participants FROM requests WHERE id=?", (request_id,)).fetchone() + current = json.loads(row["participants"]) + for e in emails: + e = (e or "").strip().lower() + if e and e not in current: + current.append(e) + con.execute("UPDATE requests SET participants=? WHERE id=?", (json.dumps(current), request_id)) + return current + + +def create_request(con, workflow_id, source, requester, subject, body, extra_participants=()): + token = secrets.token_urlsafe(32) + cur = con.execute( + "INSERT INTO requests (workflow_id, source, requester, subject, body, token, created_at) " + "VALUES (?,?,?,?,?,?,?)", + (workflow_id, source, requester, subject, body, token, now()), + ) + rid = cur.lastrowid + add_participants(con, rid, [requester, *extra_participants]) + log(con, rid, "received", f"Request received via {source} from {requester}") + return rid, token diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..32e4353 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + approvalflow: + build: . + ports: + - "8000:8000" + env_file: .env + volumes: + - ./data:/data + restart: unless-stopped diff --git a/mailer.py b/mailer.py new file mode 100644 index 0000000..51c7fc9 --- /dev/null +++ b/mailer.py @@ -0,0 +1,145 @@ +"""Outbound email via Purelymail, and template rendering. + +Preconfigured for Purelymail — only the account's login and password are needed: + + MAIL_USER=you@yourdomain.com (or you@purelymail.com) + MAIL_PASSWORD=... (use an App Password if 2FA is enabled) + +Every message is sent through SMTP (smtp.purelymail.com:465, SSL/TLS) and a +copy is saved to the account's Sent folder over IMAP +(imap.purelymail.com:993, SSL/TLS). Every attempt — success or failure — is +recorded in the mail_log table shown on the admin "Mail status" page. +""" +import html as html_mod +import imaplib +import os +import smtplib +import time +from email.message import EmailMessage +from email.utils import make_msgid + +import db + +SMTP_HOST = "smtp.purelymail.com" +SMTP_PORT = 465 # SSL/TLS +IMAP_HOST = "imap.purelymail.com" +IMAP_PORT = 993 # SSL/TLS + +SENT_FOLDER_CANDIDATES = ("Sent", "INBOX.Sent", "Sent Messages", "Sent Items") + + +def render(template: str, **vars) -> str: + """Safe {placeholder} substitution — unknown placeholders are left as-is.""" + out = template + for k, v in vars.items(): + out = out.replace("{" + k + "}", str(v)) + return out + + +def _save_to_sent(user, password, msg) -> str: + """Append a copy of the message to the account's Sent folder. Returns a status string.""" + try: + with imaplib.IMAP4_SSL(IMAP_HOST, IMAP_PORT) as imap: + imap.login(user, password) + raw = msg.as_bytes() + stamp = imaplib.Time2Internaldate(time.time()) + last_err = "" + for folder in SENT_FOLDER_CANDIDATES: + status, resp = imap.append(folder, r"(\Seen)", stamp, raw) + if status == "OK": + return f"saved to '{folder}'" + last_err = (resp[0] or b"").decode("utf-8", "replace") if resp else "" + # No known folder worked — create "Sent" and try once more. + imap.create("Sent") + status, resp = imap.append("Sent", r"(\Seen)", stamp, raw) + if status == "OK": + return "saved to 'Sent' (folder created)" + return f"failed: {last_err or 'could not append to any Sent folder'}" + except Exception as e: + return f"failed: {e}" + + +def _record(request_id, recipients, subject, status, detail, sent_copy): + try: + con = db.get_db() + con.execute( + "INSERT INTO mail_log (at, request_id, recipients, subject, status, detail, sent_copy)" + " VALUES (?,?,?,?,?,?,?)", + (db.now(), request_id, ", ".join(recipients), subject, status, detail, sent_copy)) + con.commit() + con.close() + except Exception as e: + print(f"[mail] Could not record mail log entry: {e}") + + +def send_mail(to_addrs, subject, body, request_id=None, attachments=None, inline_images=None): + """Send an email through Purelymail, keep a copy in the Sent folder, and + record the attempt in the mail log. Returns (ok, detail). + + attachments: optional list of (filename, bytes, maintype, subtype). + inline_images: optional dict {marker_name: (bytes, subtype)}. When given, + an HTML alternative is built where each "{image:marker_name}" + in the body becomes an embedded ; the plain-text part + shows "[image: marker_name]" instead. + """ + if isinstance(to_addrs, str): + to_addrs = [to_addrs] + to_addrs = [a for a in to_addrs if a] + if not to_addrs: + return False, "no recipients" + + user = os.environ.get("MAIL_USER", "") + password = os.environ.get("MAIL_PASSWORD", "") + + msg = EmailMessage() + msg["From"] = user or "approvalflow@localhost" + msg["To"] = ", ".join(to_addrs) + msg["Subject"] = subject + + if inline_images: + text = body + for name in inline_images: + text = text.replace("{image:" + name + "}", f"[image: {name}]") + msg.set_content(text) + + html = html_mod.escape(body).replace("\n", "
\n") + cids = {} + for name in inline_images: + cid = make_msgid(domain="martinhal.approvalflow") + cids[name] = cid + html = html.replace("{image:" + name + "}", + f'{html_mod.escape(name)}') + msg.add_alternative( + f'
{html}
', + subtype="html") + html_part = msg.get_payload()[-1] + for name, (data, subtype) in inline_images.items(): + html_part.add_related(data, maintype="image", subtype=subtype, cid=cids[name]) + else: + msg.set_content(body) + + for filename, data, maintype, subtype in (attachments or []): + msg.add_attachment(data, maintype=maintype, subtype=subtype, filename=filename) + + if not user: + # Dev mode: print instead of sending so the app is testable without an account. + att = "".join(f"\n[attachment: {f} ({len(b)} bytes)]" for f, b, *_ in (attachments or [])) + img = "".join(f"\n[inline image: {n} ({len(d)} bytes, {s})]" for n, (d, s) in (inline_images or {}).items()) + print(f"[mail:DEV] To: {msg['To']}\nSubject: {subject}\n{body}{att}{img}\n{'-'*50}") + _record(request_id, to_addrs, subject, "dev", + "MAIL_USER not set — printed to console", "skipped (dev mode)") + return True, "dev mode (MAIL_USER not set) — printed to console" + + try: + with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=30) as server: + server.login(user, password) + server.send_message(msg) + except Exception as e: + detail = f"SMTP error: {e}" + _record(request_id, to_addrs, subject, "error", detail, "skipped (send failed)") + return False, detail + + sent_copy = _save_to_sent(user, password, msg) + _record(request_id, to_addrs, subject, "sent", "", sent_copy) + return True, f"sent to {', '.join(to_addrs)}" diff --git a/pipeline.py b/pipeline.py new file mode 100644 index 0000000..8db535a --- /dev/null +++ b/pipeline.py @@ -0,0 +1,292 @@ +"""Request pipeline for requests created on the site. + +Each workflow has an ordered chain of approvers. A request moves through the +chain one approver at a time: + + - DENY at any step -> the workflow STOPS; everyone involved so far gets + the denied notice. + - APPROVE -> the request moves to the next approver in the chain. + - APPROVE (last) -> the request is fully approved; everyone involved so + far gets the approved notice. + +Each step gets a fresh secret token, so an earlier approver's links cannot +decide later steps. +""" +import json +import mimetypes +import os +import secrets + +import db +from mailer import render, send_mail + +BASE_URL = os.environ.get("BASE_URL", "http://localhost:8000").rstrip("/") + + +def approver_chain(wf): + try: + chain = json.loads(wf["approvers"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def _vars(wf, req, **extra): + chain = approver_chain(wf) + v = { + "workflow": wf["name"], + "requester": req["requester"], + "subject": req["subject"], + "body": req["body"], + "request_id": req["id"], + "created_at": req["created_at"], + "step": min(req["current_step"], max(len(chain) - 1, 0)) + 1, + "total_steps": len(chain), + "approve_url": f"{BASE_URL}/decision/{req['token']}/approve", + "deny_url": f"{BASE_URL}/decision/{req['token']}/deny", + } + v.update(extra) + return v + + +def _record_decision(con, request_id, step, decided_by, decision): + con.execute("INSERT INTO decisions (request_id, step, decided_by, decision, at) VALUES (?,?,?,?,?)", + (request_id, step, decided_by, decision, db.now())) + + +def build_summary(con, req, total_steps): + """Human-readable history: when the request was created and who + approved (or denied) each step, with timestamps.""" + lines = [f" Created {req['created_at']} by {req['requester']}"] + for d in con.execute("SELECT * FROM decisions WHERE request_id=? ORDER BY id", (req["id"],)): + verb = "Approved" if d["decision"] == "approved" else "DENIED" + lines.append(f" Step {d['step']}/{total_steps} {verb} {d['at']} by {d['decided_by']}") + return "\n".join(lines) + + +def collect_inline_images(rendered_body): + """Find {image:name} markers in a rendered body and load those images + from the email image library. Returns a dict for send_mail, or None.""" + import re + names = set(re.findall(r"\{image:([A-Za-z0-9._\-]+)\}", rendered_body)) + images = {} + for name in names: + ext = os.path.splitext(name)[1].lower() + subtype = db.EMAIL_IMAGE_TYPES.get(ext) + path = os.path.join(db.EMAIL_IMG_DIR, name) + if subtype and os.path.isfile(path): + with open(path, "rb") as f: + images[name] = (f.read(), subtype) + return images or None + + +def attachment_list(req): + """Parse the request's attachment column into [{"stored":…, "name":…}, …].""" + try: + return json.loads(req["attachment"] or "[]") + except Exception: + return [] + + +def load_attachments(req): + """Load every uploaded file of the request as send_mail attachments.""" + out = [] + for entry in attachment_list(req): + path = db.attachment_path(entry["stored"]) + try: + with open(path, "rb") as f: + data = f.read() + except OSError: + continue + ctype = mimetypes.guess_type(entry["name"])[0] or "application/octet-stream" + maintype, subtype = ctype.split("/", 1) + out.append((entry["name"], data, maintype, subtype)) + return out or None + + +def knowledge_chain(wf): + try: + chain = json.loads(wf["knowledge"]) + except Exception: + chain = [] + return [a.strip() for a in chain if a and a.strip()] + + +def notify_knowledge(request_id, event): + """Inform the workflow's knowledge chain of an event. These addresses are + never part of the approval chain — they only receive FYI messages.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + watchers = knowledge_chain(wf) + if not watchers: + return + chain = approver_chain(wf) + subject = f"[{wf['name']}] FYI — request #{req['id']}: {event}" + body = ( + f"For your information (you are on the knowledge chain of this workflow —\n" + f"no action is needed from you):\n\n" + f"{event}\n\n" + f"Workflow: {wf['name']}\n" + f"From: {req['requester']}\n" + f"Created: {req['created_at']}\n" + f"Subject: {req['subject']}\n" + f"Status: {req['status'].upper()}" + f" (step {min(req['current_step'], max(len(chain)-1, 0)) + 1} of {len(chain)})\n\n" + f"Message:\n{req['body']}\n" + ) + if req["deny_reason"]: + body += f"\nReason for denial:\n{req['deny_reason']}\n" + ok, detail = send_mail(watchers, subject, body, request_id=request_id, + attachments=load_attachments(req)) + db.log(con, request_id, + "knowledge chain informed" if ok else "mail error", + f"'{event}' to {', '.join(watchers)} — {detail}") + con.commit() + finally: + con.close() + + +def current_approver(wf, req): + chain = approver_chain(wf) + if not chain: + return None + return chain[min(req["current_step"], len(chain) - 1)] + + +def send_approval_request(request_id): + """Send the approval request to the approver of the CURRENT step.""" + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE id=?", (request_id,)).fetchone() + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + approver = current_approver(wf, req) + if approver is None: + db.log(con, request_id, "error", f"Workflow '{wf['name']}' has no approvers configured") + con.commit() + return False + con.commit() # release any pending writes before slow mail I/O + v = _vars(wf, req) + body_t = wf["approval_body"] + if "{created_at}" not in body_t: # creation time is mandatory in every mail + body_t = "Request created: {created_at}\n\n" + body_t + attachments = load_attachments(req) + rendered_body = render(body_t, **v) + ok, detail = send_mail(approver, + render(wf["approval_subject"], **v), + rendered_body, + request_id=request_id, + attachments=attachments, + inline_images=collect_inline_images(rendered_body)) + db.add_participants(con, request_id, [approver]) + db.log(con, request_id, + "approval requested" if ok else "mail error", + f"Step {req['current_step'] + 1}/{len(chain)}: approval request to {approver} — {detail}") + con.commit() + return ok + finally: + con.close() + + +def _notify_outcome(con, wf, req, new_status, decided_by): + participants = db.add_participants(con, req["id"], [decided_by]) + summary = build_summary(con, req, len(approver_chain(wf))) + con.commit() # release the write lock before slow mail I/O (mail_log uses its own connection) + v = _vars(wf, req, decided_by=decided_by, decision=new_status.upper(), + summary=summary, deny_reason=req["deny_reason"]) + subj_t = wf["approved_subject"] if new_status == "approved" else wf["denied_subject"] + body_t = wf["approved_body"] if new_status == "approved" else wf["denied_body"] + # These are mandatory in the outcome notice: append them if the workflow's + # custom template doesn't include the placeholder. + if new_status == "approved" and "{summary}" not in body_t: + body_t += "\n\nSummary:\n{summary}\n" + if new_status == "denied" and "{deny_reason}" not in body_t: + body_t += "\n\nReason for denial:\n{deny_reason}\n" + if "{created_at}" not in body_t and "{summary}" not in body_t: + # creation time is mandatory in every mail ({summary} already includes it) + body_t = "Request created: {created_at}\n\n" + body_t + rendered_body = render(body_t, **v) + ok, detail = send_mail(participants, render(subj_t, **v), rendered_body, + request_id=req["id"], + inline_images=collect_inline_images(rendered_body)) + db.log(con, req["id"], + "participants notified" if ok else "mail error", + f"{new_status.capitalize()} notice to {', '.join(participants)} — {detail}") + + +def decide(token, action, decided_by, reason=""): + """Handle a decision at the request's current step. + + A denial requires a reason (callers enforce it as a mandatory field; + this is a second line of defense). + + Returns (status, request_row_or_None, message). + """ + assert action in ("approve", "deny") + reason = (reason or "").strip() + con = db.get_db() + try: + req = con.execute("SELECT * FROM requests WHERE token=?", (token,)).fetchone() + if req is None: + return "notfound", None, "This link is not valid — it may belong to a step that was already decided." + if req["status"] != "pending": + return "already", req, f"This request was already {req['status']}." + + wf = con.execute("SELECT * FROM workflows WHERE id=?", (req["workflow_id"],)).fetchone() + chain = approver_chain(wf) + step = req["current_step"] + + if action == "deny": + if not reason: + return "needreason", req, "A reason for denial is required." + # STOP the workflow, notify everyone involved so far. + con.execute("UPDATE requests SET status='denied', decided_by=?, decided_at=?, deny_reason=? WHERE id=?", + (decided_by, db.now(), reason, req["id"])) + _record_decision(con, req["id"], step + 1, decided_by, "denied") + db.log(con, req["id"], "denied", + f"Denied at step {step + 1}/{len(chain)} by {decided_by} — workflow stopped. Reason: {reason}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "denied", decided_by) + con.commit() + notify_knowledge(req["id"], f"DENIED at step {step + 1}/{len(chain)} by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} denied at step {step + 1} of {len(chain)}. " + "The workflow has stopped and everyone involved has been notified.") + + # approve + _record_decision(con, req["id"], step + 1, decided_by, "approved") + db.log(con, req["id"], "step approved", + f"Step {step + 1}/{len(chain)} approved by {decided_by}") + db.add_participants(con, req["id"], [decided_by]) + con.commit() + + if step + 1 >= len(chain): + # Last approver — fully approved. + con.execute("UPDATE requests SET status='approved', decided_by=?, decided_at=? WHERE id=?", + (decided_by, db.now(), req["id"])) + db.log(con, req["id"], "approved", + f"Final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + _notify_outcome(con, wf, req, "approved", decided_by) + con.commit() + notify_knowledge(req["id"], + f"FULLY APPROVED — final approval ({len(chain)}/{len(chain)}) by {decided_by}") + req = con.execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Request #{req['id']} fully approved ({len(chain)} of {len(chain)} steps). " + "Everyone involved has been notified.") + + # Move to the next approver with a fresh token (old links stop working). + new_token = secrets.token_urlsafe(32) + con.execute("UPDATE requests SET current_step=?, token=? WHERE id=?", + (step + 1, new_token, req["id"])) + con.commit() + notify_knowledge(req["id"], f"Step {step + 1}/{len(chain)} approved by {decided_by} — " + f"now waiting on {chain[step + 1]}") + send_approval_request(req["id"]) + req = db.get_db().execute("SELECT * FROM requests WHERE id=?", (req["id"],)).fetchone() + return "ok", req, (f"Step {step + 1} of {len(chain)} approved. The request was sent to the " + f"next approver ({chain[step + 1]}).") + finally: + con.close() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..be005c9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +flask>=3.0 +werkzeug>=3.0 +gunicorn>=21.0 diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..73489e0 --- /dev/null +++ b/static/style.css @@ -0,0 +1,195 @@ +/* ApprovalFlow — cool paper + ink, rubber-stamp statuses, mono ledger */ +:root, [data-theme="light"] { + --paper: #fafbfc; + --card: #ffffff; + --field: #ffffff; + --hover: #f2f6f8; + --ink: #16232e; + --ink-soft: #5a6b78; + --line: #dfe5ea; + --approve: #0e7c66; + --deny: #b3372f; + --pending: #b7791f; + --focus: #2563a8; + --flash-ok-bg: #e4f3ee; --flash-ok-line: #bfe0d5; --flash-ok-ink: #0b5c4c; + --flash-err-bg: #f9e8e6; --flash-err-line: #edc7c3; --flash-err-ink: #872a24; + --radius: 8px; + font-size: 16px; +} +[data-theme="dark"] { + --paper: #10161c; + --card: #18212a; + --field: #10161c; + --hover: #1e2a35; + --ink: #e4ebf1; + --ink-soft: #93a5b2; + --line: #2b3945; + --approve: #35b598; + --deny: #e07067; + --pending: #d9a24a; + --focus: #6aa9e0; + --flash-ok-bg: #12352c; --flash-ok-line: #1d5a4a; --flash-ok-ink: #8fd8c3; + --flash-err-bg: #3c1f1c; --flash-err-line: #6b3530; --flash-err-ink: #f0b1aa; +} +[data-theme="dark"] body { color-scheme: dark; } +[data-theme="dark"] button, [data-theme="dark"] .btn { + border-color: var(--line); background: #26323e; +} +[data-theme="dark"] button:hover, [data-theme="dark"] .btn:hover { background: #31404e; } +[data-theme="dark"] .btn.approve { background: #14523f; border-color: #14523f; } +[data-theme="dark"] .btn.deny { background: #79312a; border-color: #79312a; } +[data-theme="dark"] .btn.ghost { background: transparent; color: var(--ink); } +* { box-sizing: border-box; } +body { + margin: 0; background: var(--paper); color: var(--ink); + font-family: "IBM Plex Sans", system-ui, sans-serif; line-height: 1.55; +} +.mono, code, .ledger, .stamp, .chip { font-family: "IBM Plex Mono", ui-monospace, monospace; } +a { color: var(--focus); text-decoration: none; } +a:hover { text-decoration: underline; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } + +/* top bar */ +.topbar { + display: flex; align-items: center; gap: 2rem; flex-wrap: wrap; + padding: .8rem 1.5rem; background: var(--ink); color: #e9eef2; +} +.brand { font-weight: 700; font-size: 1.05rem; color: #fff; letter-spacing: .02em; } +.brand span { color: #7fd6c2; } +.topbar nav { display: flex; gap: 1.2rem; flex: 1; } +.topbar nav a { color: #b9c6cf; padding: .2rem 0; border-bottom: 2px solid transparent; } +.topbar nav a.on, .topbar nav a:hover { color: #fff; border-bottom-color: #7fd6c2; text-decoration: none; } +.who { display: flex; gap: 1rem; align-items: center; font-size: .9rem; } +.who a { color: #b9c6cf; } + +.wrap { max-width: 980px; margin: 2rem auto 4rem; padding: 0 1.25rem; } + +h1 { font-size: 1.6rem; margin: 0 0 .3rem; } +h2 { font-size: 1.15rem; margin: 2rem 0 .8rem; } +.sub { color: var(--ink-soft); margin: 0 0 1.6rem; } + +.card { + background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: 1.4rem 1.5rem; margin-bottom: 1.4rem; +} + +/* forms */ +label { display: block; font-weight: 600; font-size: .85rem; margin: 1rem 0 .3rem; } +label small { font-weight: 400; color: var(--ink-soft); } +input[type=text], input[type=password], input[type=email], select, textarea, input[type=file] { + width: 100%; padding: .55rem .7rem; font: inherit; color: var(--ink); + border: 1px solid var(--line); border-radius: 6px; background: var(--field); +} +input[type=file] { padding: .4rem .5rem; } +textarea { min-height: 130px; resize: vertical; } +textarea.tpl { font-family: "IBM Plex Mono", monospace; font-size: .85rem; min-height: 110px; } +.check { display: flex; gap: .5rem; align-items: center; font-weight: 400; margin-top: 1rem; } +.check input { width: auto; } + +button, .btn { + display: inline-block; margin-top: 1.1rem; padding: .55rem 1.3rem; + font: inherit; font-weight: 600; border-radius: 6px; cursor: pointer; + border: 1px solid var(--ink); background: var(--ink); color: #fff; +} +button:hover, .btn:hover { background: #223546; text-decoration: none; } +.btn.approve { background: var(--approve); border-color: var(--approve); } +.btn.deny { background: var(--deny); border-color: var(--deny); } +.btn.ghost { background: transparent; color: var(--ink); } +.btn.small, button.small { padding: .3rem .8rem; font-size: .82rem; margin-top: 0; } + +/* the signature: rubber-stamp status */ +.stamp { + display: inline-block; padding: .1rem .55rem; font-size: .72rem; font-weight: 600; + letter-spacing: .12em; text-transform: uppercase; border: 2px solid; + border-radius: 4px; transform: rotate(-2deg); +} +.stamp.approved { color: var(--approve); border-color: var(--approve); } +.stamp.denied { color: var(--deny); border-color: var(--deny); } +.stamp.pending { color: var(--pending); border-color: var(--pending); } +.stamp.big { font-size: 1.1rem; padding: .3rem 1rem; } + +.chip { + display: inline-block; font-size: .72rem; padding: .05rem .5rem; + border: 1px solid var(--line); border-radius: 999px; color: var(--ink-soft); +} + +/* tables */ +table { width: 100%; border-collapse: collapse; font-size: .9rem; } +th { text-align: left; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; + color: var(--ink-soft); padding: .5rem .6rem; border-bottom: 2px solid var(--line); } +td { padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; } +tr:hover td { background: var(--hover); } + +/* ledger (audit log) */ +.ledger { list-style: none; margin: 0; padding: 0; font-size: .85rem; } +.ledger li { + display: grid; grid-template-columns: 175px 170px 1fr; gap: 1rem; + padding: .55rem .2rem; border-bottom: 1px dashed var(--line); +} +.ledger .t { color: var(--ink-soft); } +.ledger .e { font-weight: 600; } +@media (max-width: 700px) { .ledger li { grid-template-columns: 1fr; gap: .1rem; } } + +/* stat row */ +.stats { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; } +.stat { flex: 1 1 120px; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); padding: .8rem 1rem; } +.stat b { display: block; font-size: 1.5rem; font-family: "IBM Plex Mono", monospace; } +.stat span { font-size: .78rem; text-transform: uppercase; letter-spacing: .08em; color: var(--ink-soft); } + +.flash { padding: .7rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: .92rem; } +.flash.ok { background: var(--flash-ok-bg); border: 1px solid var(--flash-ok-line); color: var(--flash-ok-ink); } +.flash.error { background: var(--flash-err-bg); border: 1px solid var(--flash-err-line); color: var(--flash-err-ink); } + +/* login */ +.login-box { max-width: 380px; margin: 12vh auto 0; } +.login-box .brand-lg { font-size: 1.7rem; font-weight: 700; } +.login-box .brand-lg span { color: var(--approve); } + +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0 1.5rem; } +@media (max-width: 700px) { .grid2 { grid-template-columns: 1fr; } } + +.rowline { display: flex; gap: .6rem; align-items: center; flex-wrap: wrap; } +form.inline { display: inline; } +details { margin-top: .4rem; } +summary { cursor: pointer; font-size: .85rem; color: var(--focus); } +.muted { color: var(--ink-soft); font-size: .85rem; } +.pre { white-space: pre-wrap; font-size: .9rem; background: var(--hover); border: 1px solid var(--line); + border-radius: 6px; padding: .8rem 1rem; } + +@media (prefers-reduced-motion: no-preference) { + .stamp.big { animation: stampIn .25s ease-out; } + @keyframes stampIn { from { transform: rotate(-2deg) scale(1.6); opacity: 0; } + to { transform: rotate(-2deg) scale(1); opacity: 1; } } +} + +/* footer */ +.footer { + max-width: 980px; margin: 0 auto; padding: 1.2rem 1.25rem 2rem; + font-size: .8rem; color: var(--ink-soft); text-align: center; + border-top: 1px solid var(--line); +} + +/* theme toggle */ +.theme-toggle { + margin: 0; padding: .15rem .55rem; font-size: 1rem; line-height: 1; + background: transparent; border: 1px solid #4a5b68; color: #b9c6cf; + border-radius: 999px; cursor: pointer; +} +.theme-toggle:hover { color: #fff; border-color: #7fd6c2; background: transparent; } +.theme-toggle.floating { + position: fixed; top: 1rem; right: 1rem; z-index: 10; + border-color: var(--line); color: var(--ink-soft); +} +.theme-toggle.floating:hover { color: var(--ink); border-color: var(--focus); } + +/* email image library */ +.imggrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 1rem; } +.imgcard { border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; background: var(--field); } +.imgcard img { display: block; width: 100%; height: 110px; object-fit: contain; background: #fff; } +.imgmeta { padding: .5rem .6rem; display: flex; flex-direction: column; gap: .35rem; font-size: .78rem; } +.imgmeta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.imgmeta .tag { + background: var(--hover); border: 1px dashed var(--line); border-radius: 5px; + padding: .15rem .4rem; cursor: copy; word-break: break-all; white-space: normal; +} diff --git a/templates/account.html b/templates/account.html new file mode 100644 index 0000000..90eb590 --- /dev/null +++ b/templates/account.html @@ -0,0 +1,96 @@ +{% extends "base.html" %} +{% block title %}Configure profile — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Configure profile

+

Signed in as {{ user.username }}

+ +
+

Profile

+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Change password

+
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ +
+
+ +
+

Two-factor authentication (MFA)

+ + {% if user.mfa_secret %} +

enabled

+

Signing in requires your password and a 6-digit code from your authenticator app.

+
+ + + + +
+ + {% elif setup_secret %} +

1 — Scan this QR code with Google Authenticator, Authy, 1Password, or any TOTP app. + If you can't scan, enter the secret manually.

+
+

Secret: {{ setup_secret }}

+

2 — Enter the 6-digit code the app shows to confirm.

+
+ + + +
+
+ + +
+ + + + {% else %} +

disabled

+

Add a second sign-in step: your password plus a 6-digit code from an authenticator app on your phone.

+
+ + +
+ {% endif %} +
+{% endblock %} diff --git a/templates/admin.html b/templates/admin.html new file mode 100644 index 0000000..d42a305 --- /dev/null +++ b/templates/admin.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}Workflows & requests — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Workflows & requests

+

Create workflows, watch every request, and open any of them for the full audit trail.

+ +
+
{{ stats.get('pending', 0) }}Pending
+
{{ stats.get('approved', 0) }}Approved
+
{{ stats.get('denied', 0) }}Denied
+
+ +

Workflows

+
+ + + {% for w in workflows %} + + + + + + + + {% else %} + + {% endfor %} +
NameApproval chainKnowledge chainStatus
{{ w.name }}{% if w.description %}
{{ w.description }}{% endif %}
{% for a in w.approvers | fromjson %}{{ loop.index }}. {{ a }}{% if not loop.last %}
{% endif %}{% endfor %}
{% for a in w.knowledge | fromjson %}{{ a }}{% if not loop.last %}
{% endif %}{% else %}{% endfor %}
{% if w.active %}active{% else %}paused{% endif %}Edit
No workflows yet — create the first one.
+ New workflow +
+ +

Requests

+
+ + + {% for r in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
#WorkflowFromSubjectStatusReceived
{{ r.id }}{{ r.wf_name }}{{ r.requester }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
No requests yet.
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..26533d9 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,52 @@ + + + + + +{% block title %}MartinhalApprovalFlow{% endblock %} + + + + + + +{% if session.get('user_id') %} +
+ MartinhalApprovalFlow + +
+ + Configure profile + {{ session['username'] }} + Sign out +
+
+{% else %} + +{% endif %} +
+ {% with msgs = get_flashed_messages(with_categories=true) %} + {% for cat, m in msgs %}
{{ m }}
{% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
+
© 2026 Martinhal IT - Joao Vaz - Version 9.3
+ + diff --git a/templates/decision.html b/templates/decision.html new file mode 100644 index 0000000..4e54315 --- /dev/null +++ b/templates/decision.html @@ -0,0 +1,65 @@ +{% extends "base.html" %} +{% block title %}Decision — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..00215d2 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% block title %}New request — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Send a request for approval

+

Pick a workflow, write your message, and it goes straight to that workflow's approver.

+ +{% if workflows %} +
+
+ + + + + + + + + + + +
+
+{% else %} +
+

You don't have access to any workflow yet. Ask an administrator to grant you access.

+
+{% endif %} + +{% if my_requests %} +

Your recent requests

+
+ + + {% for r in my_requests %} + + + + + + + + {% endfor %} +
#WorkflowSubjectStatusSent
{{ r.id }}{{ r.wf_name }}{{ r.subject }}{{ r.status }}{{ r.created_at }}
+
+{% endif %} +{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..6254420 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}Sign in — MartinhalApprovalFlow{% endblock %} +{% block content %} + +{% endblock %} diff --git a/templates/mail_status.html b/templates/mail_status.html new file mode 100644 index 0000000..929da67 --- /dev/null +++ b/templates/mail_status.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} +{% block title %}Mail status — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Mail status

+

Every email the app tried to send, with its delivery result and whether a copy was saved to the Sent folder.

+ +{% if not configured %} +
Mail account not configured — MAIL_USER is empty, so messages are printed to the server console (dev mode) instead of being sent.
+{% endif %} + +
+
{{ stats.get('sent', 0) }}Sent
+
{{ stats.get('error', 0) }}Errors
+
{{ copy_fails }}Sent-copy failures
+
{{ stats.get('dev', 0) }}Dev mode
+
+ +
+ + + {% for m in rows %} + + + + + + + + + {% else %} + + {% endfor %} +
WhenToSubjectRequestDeliverySent folder
{{ m.at }}{{ m.recipients }}{{ m.subject }} + {% if m.detail %}
{{ m.detail }}{% endif %}
{% if m.request_id %}#{{ m.request_id }}{% else %}—{% endif %} + {% if m.status == 'sent' %}sent + {% elif m.status == 'error' %}error + {% else %}dev{% endif %} + + {% if m.sent_copy.startswith('saved') %}{{ m.sent_copy }} + {% elif m.sent_copy.startswith('failed') %}copy failed
{{ m.sent_copy }} + {% else %}{{ m.sent_copy }}{% endif %} +
No mail has been sent yet.
+
+{% endblock %} diff --git a/templates/request_detail.html b/templates/request_detail.html new file mode 100644 index 0000000..b6e8c1c --- /dev/null +++ b/templates/request_detail.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}Request #{{ req.id }} — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All requests

+
+

Request #{{ req.id }}

+ {{ req.status }} +
+

Workflow {{ req.wf_name }} · received {{ req.created_at }}

+ +
+

From: {{ req.requester }}
+ Subject: {{ req.subject }}

+
{{ req.body }}
+ {% if attachments %} +

Attachments (included in every approval mail):
+ {% for att in attachments %} + {{ att.name }}{% if not loop.last %} · {% endif %} + {% endfor %} +

+ {% endif %} + {% if req.decided_by %} +

Final decision by {{ req.decided_by }} at {{ req.decided_at }}

+ {% endif %} + {% if req.status == 'denied' and req.deny_reason %} +

Reason for denial:

+
{{ req.deny_reason }}
+ {% endif %} + {% if req.status == 'pending' %} +
+
+ + Same effect as the current approver's email links. +
+
+
+ + + +
+ {% endif %} +
+ +

Approval chain

+
+
    + {% for a in chain %} +
  • + step {{ loop.index }} / {{ chain | length }} + {{ a }} + + {% if req.status == 'denied' and loop.index0 == req.current_step %}denied here + {% elif loop.index0 < req.current_step or req.status == 'approved' %}approved + {% elif loop.index0 == req.current_step and req.status == 'pending' %}waiting + {% elif req.status == 'denied' %}never reached — workflow stopped + {% else %}not yet reached{% endif %} + +
  • + {% endfor %} +
+
+ +

People involved so far

+
+ {% for p in participants %}{{ p }} {% else %}None recorded.{% endfor %} +
+ +

Full log

+
+
    + {% for l in logs %} +
  • {{ l.at }}{{ l.event }}{{ l.detail }}
  • + {% endfor %} +
+
+ + +
+
+{% endblock %} diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..3f658f9 --- /dev/null +++ b/templates/users.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}Users — MartinhalApprovalFlow{% endblock %} +{% block content %} +

Users

+

Create accounts and choose which workflows each user can send requests to. Admins can use every workflow. Users fill in their own details (name, unit, e-mail, MFA) on their Configure profile page; you can disable MFA here if someone loses their device.

+ +
+

Add a user

+
+ +
+
+
+
+
+
+ + +
+
+ +{% for u in users %} +
+
+ {{ u.username }} + {% if u.first_name or u.last_name %}{{ u.first_name }} {{ u.last_name }}{% endif %} + {% if u.unit_location %}{{ u.unit_location }}{% endif %} + {% if u.is_admin %}admin{% endif %} + {% if u.mfa_secret %}MFA on{% endif %} + {% if u.email %}{{ u.email }}{% endif %} + + {% if u.mfa_secret %} +
+ + + +
+ {% endif %} + {% if u.id != session['user_id'] %} +
+ + + +
+ {% endif %} +
+ + {% if not u.is_admin %} +
+ + + + {% for w in workflows %} + + {% else %} + No workflows exist yet. + {% endfor %} + {% if workflows %}{% endif %} +
+ {% endif %} + +
+ Reset password +
+ + + + +
+
+
+{% endfor %} +{% endblock %} diff --git a/templates/workflow_edit.html b/templates/workflow_edit.html new file mode 100644 index 0000000..47202ae --- /dev/null +++ b/templates/workflow_edit.html @@ -0,0 +1,99 @@ +{% extends "base.html" %} +{% block title %}{{ 'Edit' if wf else 'New' }} workflow — MartinhalApprovalFlow{% endblock %} +{% block content %} +

← All workflows

+

{{ 'Edit workflow' if wf else 'New workflow' }}

+

Each workflow has its own allowed sender domain, its own approver, and its own email wording.

+ +
+
+

Basics

+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ +
+ +
+

Email wording

+

Placeholders: {workflow} {requester} {subject} {body} {request_id} {step} {total_steps} {decided_by} {created_at} {summary} {deny_reason} {approve_url} {deny_url}. Leave a field empty to use the default.

+ + + + + + +
+
+ + + + +
+
+ + + + +
+
+
+ + +
+ +
+

Images for email bodies

+

These images can be used in the approval request body, approved notice body and + denied notice body above. Copy an image's tag into the text where the image should appear — + it is embedded in the email at that spot. PNG, JPG or GIF, up to 2 MB each. The library is shared by all workflows.

+ + {% if images %} +
+ {% for name in images %} +
+ {{ name }} +
+ {{ name }} + {{ '{image:' + name + '}' }} +
+ + + +
+
+
+ {% endfor %} +
+ {% else %} +

No images uploaded yet.

+ {% endif %} + +
+ + + +
+
+{% endblock %} diff --git a/totp.py b/totp.py new file mode 100644 index 0000000..909a241 --- /dev/null +++ b/totp.py @@ -0,0 +1,40 @@ +"""Time-based one-time passwords (TOTP, RFC 6238) using only the standard +library. Compatible with Google Authenticator, Authy, 1Password, etc.""" +import base64 +import hashlib +import hmac +import secrets +import struct +import time +from urllib.parse import quote + +ISSUER = "MartinhalApprovalFlow" + + +def new_secret() -> str: + """A new base32 secret to enroll in an authenticator app.""" + return base64.b32encode(secrets.token_bytes(20)).decode() + + +def _code_at(secret: str, counter: int) -> str: + key = base64.b32decode(secret + "=" * (-len(secret) % 8), casefold=True) + digest = hmac.new(key, struct.pack(">Q", counter), hashlib.sha1).digest() + offset = digest[-1] & 15 + number = (struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF) % 1_000_000 + return f"{number:06d}" + + +def verify(secret: str, code: str) -> bool: + """Check a 6-digit code, allowing one 30s step of clock drift each way.""" + code = (code or "").strip().replace(" ", "") + if not (secret and code.isdigit() and len(code) == 6): + return False + counter = int(time.time() // 30) + return any(hmac.compare_digest(_code_at(secret, counter + drift), code) + for drift in (-1, 0, 1)) + + +def otpauth_uri(username: str, secret: str) -> str: + """URI encoded as a QR code for authenticator apps.""" + return (f"otpauth://totp/{quote(ISSUER)}:{quote(username)}" + f"?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period=30")