"""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()