293 lines
13 KiB
Python
293 lines
13 KiB
Python
"""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()
|