323 lines
14 KiB
Python
323 lines
14 KiB
Python
"""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/<token>/<action>", 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/<int:rid>", 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/<int:wid>", 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")
|