From 0ebb8e7f4b1a4aa026682d8764fb33068f671f2b Mon Sep 17 00:00:00 2001 From: jpmvaz Date: Sun, 13 Sep 2026 20:03:24 +0100 Subject: [PATCH] v_9.3 --- .env.example | 18 + Dockerfile | 9 + OLD_VERSIONS/v1.0/approvalflow.zip | Bin 0 -> 23404 bytes .../approvalflow/approvalflow/.env.example | 21 + .../v1.0/approvalflow/approvalflow/Dockerfile | 9 + .../v1.0/approvalflow/approvalflow/README.md | 79 +++ .../v1.0/approvalflow/approvalflow/app.py | 284 +++++++++ .../v1.0/approvalflow/approvalflow/db.py | 157 +++++ .../approvalflow/docker-compose.yml | 9 + .../v1.0/approvalflow/approvalflow/mailer.py | 54 ++ .../approvalflow/approvalflow/pipeline.py | 76 +++ .../v1.0/approvalflow/approvalflow/poller.py | 146 +++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 136 ++++ .../approvalflow/templates/admin.html | 53 ++ .../approvalflow/templates/base.html | 35 ++ .../approvalflow/templates/decision.html | 42 ++ .../approvalflow/templates/index.html | 46 ++ .../approvalflow/templates/login.html | 15 + .../templates/request_detail.html | 40 ++ .../approvalflow/templates/users.html | 66 ++ .../approvalflow/templates/workflow_edit.html | 61 ++ OLD_VERSIONS/v2.0/approvalflow.zip | Bin 0 -> 25118 bytes .../approvalflow/approvalflow/.env.example | 21 + .../v2.0/approvalflow/approvalflow/Dockerfile | 9 + .../v2.0/approvalflow/approvalflow/README.md | 84 +++ .../v2.0/approvalflow/approvalflow/app.py | 298 +++++++++ .../v2.0/approvalflow/approvalflow/db.py | 176 ++++++ .../approvalflow/docker-compose.yml | 9 + .../v2.0/approvalflow/approvalflow/mailer.py | 54 ++ .../approvalflow/approvalflow/pipeline.py | 153 +++++ .../v2.0/approvalflow/approvalflow/poller.py | 132 ++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 136 ++++ .../approvalflow/templates/admin.html | 52 ++ .../approvalflow/templates/base.html | 35 ++ .../approvalflow/templates/decision.html | 55 ++ .../approvalflow/templates/index.html | 46 ++ .../approvalflow/templates/login.html | 15 + .../templates/request_detail.html | 59 ++ .../approvalflow/templates/users.html | 66 ++ .../approvalflow/templates/workflow_edit.html | 55 ++ OLD_VERSIONS/v3.0/approvalflow.zip | Bin 0 -> 22775 bytes .../approvalflow/approvalflow/.env.example | 13 + .../v3.0/approvalflow/approvalflow/Dockerfile | 9 + .../v3.0/approvalflow/approvalflow/README.md | 86 +++ .../v3.0/approvalflow/approvalflow/app.py | 296 +++++++++ .../v3.0/approvalflow/approvalflow/db.py | 176 ++++++ .../approvalflow/docker-compose.yml | 9 + .../v3.0/approvalflow/approvalflow/mailer.py | 53 ++ .../approvalflow/approvalflow/pipeline.py | 153 +++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 136 ++++ .../approvalflow/templates/admin.html | 50 ++ .../approvalflow/templates/base.html | 35 ++ .../approvalflow/templates/decision.html | 55 ++ .../approvalflow/templates/index.html | 46 ++ .../approvalflow/templates/login.html | 15 + .../templates/request_detail.html | 59 ++ .../approvalflow/templates/users.html | 66 ++ .../approvalflow/templates/workflow_edit.html | 54 ++ OLD_VERSIONS/v4.0 w/approvalflow.zip | Bin 0 -> 25121 bytes .../approvalflow/approvalflow/.env.example | 13 + .../approvalflow/approvalflow/Dockerfile | 9 + .../approvalflow/approvalflow/README.md | 90 +++ .../v4.0 w/approvalflow/approvalflow/app.py | 311 ++++++++++ .../v4.0 w/approvalflow/approvalflow/db.py | 189 ++++++ .../approvalflow/docker-compose.yml | 9 + .../approvalflow/approvalflow/mailer.py | 109 ++++ .../approvalflow/approvalflow/pipeline.py | 158 +++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 136 ++++ .../approvalflow/templates/admin.html | 50 ++ .../approvalflow/templates/base.html | 36 ++ .../approvalflow/templates/decision.html | 55 ++ .../approvalflow/templates/index.html | 46 ++ .../approvalflow/templates/login.html | 15 + .../approvalflow/templates/mail_status.html | 44 ++ .../templates/request_detail.html | 59 ++ .../approvalflow/templates/users.html | 66 ++ .../approvalflow/templates/workflow_edit.html | 54 ++ OLD_VERSIONS/v5.0 w/approvalflow.zip | Bin 0 -> 26299 bytes .../approvalflow/approvalflow/.env.example | 13 + .../approvalflow/approvalflow/Dockerfile | 9 + .../approvalflow/approvalflow/README.md | 90 +++ .../v5.0 w/approvalflow/approvalflow/app.py | 322 ++++++++++ .../v5.0 w/approvalflow/approvalflow/db.py | 204 ++++++ .../approvalflow/docker-compose.yml | 9 + .../approvalflow/approvalflow/mailer.py | 109 ++++ .../approvalflow/approvalflow/pipeline.py | 189 ++++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 136 ++++ .../approvalflow/templates/admin.html | 50 ++ .../approvalflow/templates/base.html | 36 ++ .../approvalflow/templates/decision.html | 60 ++ .../approvalflow/templates/index.html | 48 ++ .../approvalflow/templates/login.html | 15 + .../approvalflow/templates/mail_status.html | 44 ++ .../templates/request_detail.html | 69 +++ .../approvalflow/templates/users.html | 66 ++ .../approvalflow/templates/workflow_edit.html | 54 ++ OLD_VERSIONS/v6.0 w/approvalflow.zip | Bin 0 -> 26994 bytes .../approvalflow/approvalflow/.env.example | 13 + .../approvalflow/approvalflow/Dockerfile | 9 + .../approvalflow/approvalflow/README.md | 90 +++ .../v6.0 w/approvalflow/approvalflow/app.py | 377 ++++++++++++ .../v6.0 w/approvalflow/approvalflow/db.py | 207 +++++++ .../approvalflow/docker-compose.yml | 9 + .../approvalflow/approvalflow/mailer.py | 109 ++++ .../approvalflow/approvalflow/pipeline.py | 196 ++++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 136 ++++ .../approvalflow/templates/admin.html | 49 ++ .../approvalflow/templates/base.html | 36 ++ .../approvalflow/templates/decision.html | 60 ++ .../approvalflow/templates/index.html | 48 ++ .../approvalflow/templates/login.html | 15 + .../approvalflow/templates/mail_status.html | 44 ++ .../templates/request_detail.html | 73 +++ .../approvalflow/templates/users.html | 66 ++ .../approvalflow/templates/workflow_edit.html | 54 ++ OLD_VERSIONS/v7.0 w/approvalflow.zip | Bin 0 -> 28521 bytes .../approvalflow/approvalflow/.env.example | 13 + .../approvalflow/approvalflow/Dockerfile | 9 + .../approvalflow/approvalflow/README.md | 91 +++ .../v7.0 w/approvalflow/approvalflow/app.py | 411 +++++++++++++ .../v7.0 w/approvalflow/approvalflow/db.py | 218 +++++++ .../approvalflow/docker-compose.yml | 9 + .../approvalflow/approvalflow/mailer.py | 115 ++++ .../approvalflow/approvalflow/pipeline.py | 216 +++++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 143 +++++ .../approvalflow/templates/admin.html | 49 ++ .../approvalflow/templates/base.html | 37 ++ .../approvalflow/templates/decision.html | 63 ++ .../approvalflow/templates/index.html | 50 ++ .../approvalflow/templates/login.html | 15 + .../approvalflow/templates/mail_status.html | 44 ++ .../templates/request_detail.html | 78 +++ .../approvalflow/templates/users.html | 66 ++ .../approvalflow/templates/workflow_edit.html | 54 ++ OLD_VERSIONS/v8.0 w/approvalflow.zip | Bin 0 -> 34984 bytes .../approvalflow/approvalflow/.env.example | 13 + .../approvalflow/approvalflow/Dockerfile | 9 + .../approvalflow/approvalflow/README.md | 94 +++ .../v8.0 w/approvalflow/approvalflow/app.py | 539 ++++++++++++++++ .../v8.0 w/approvalflow/approvalflow/db.py | 227 +++++++ .../approvalflow/docker-compose.yml | 9 + .../approvalflow/approvalflow/mailer.py | 145 +++++ .../approvalflow/approvalflow/pipeline.py | 236 +++++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 195 ++++++ .../approvalflow/templates/account.html | 47 ++ .../approvalflow/templates/admin.html | 49 ++ .../approvalflow/templates/base.html | 52 ++ .../approvalflow/templates/decision.html | 63 ++ .../approvalflow/templates/index.html | 50 ++ .../approvalflow/templates/login.html | 26 + .../approvalflow/templates/mail_status.html | 44 ++ .../templates/request_detail.html | 78 +++ .../approvalflow/templates/users.html | 74 +++ .../approvalflow/templates/workflow_edit.html | 90 +++ .../v8.0 w/approvalflow/approvalflow/totp.py | 40 ++ OLD_VERSIONS/v9.0 final/approvalflow.zip | Bin 0 -> 36071 bytes .../approvalflow/approvalflow/.env.example | 18 + .../approvalflow/approvalflow/Dockerfile | 9 + .../approvalflow/approvalflow/README.md | 97 +++ .../approvalflow/approvalflow/app.py | 566 +++++++++++++++++ .../approvalflow/approvalflow/db.py | 234 +++++++ .../approvalflow/docker-compose.yml | 9 + .../approvalflow/approvalflow/mailer.py | 145 +++++ .../approvalflow/approvalflow/pipeline.py | 236 +++++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 195 ++++++ .../approvalflow/templates/account.html | 96 +++ .../approvalflow/templates/admin.html | 49 ++ .../approvalflow/templates/base.html | 52 ++ .../approvalflow/templates/decision.html | 63 ++ .../approvalflow/templates/index.html | 50 ++ .../approvalflow/templates/login.html | 26 + .../approvalflow/templates/mail_status.html | 44 ++ .../templates/request_detail.html | 78 +++ .../approvalflow/templates/users.html | 76 +++ .../approvalflow/templates/workflow_edit.html | 90 +++ .../approvalflow/approvalflow/totp.py | 40 ++ OLD_VERSIONS/v9.1 patch/approvalflow.zip | Bin 0 -> 37734 bytes .../approvalflow/approvalflow/.env.example | 18 + .../approvalflow/approvalflow/Dockerfile | 9 + .../approvalflow/approvalflow/README.md | 116 ++++ .../approvalflow/approvalflow/app.py | 579 ++++++++++++++++++ .../approvalflow/approvalflow/db.py | 249 ++++++++ .../approvalflow/docker-compose.yml | 9 + .../approvalflow/approvalflow/mailer.py | 145 +++++ .../approvalflow/approvalflow/pipeline.py | 291 +++++++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 195 ++++++ .../approvalflow/templates/account.html | 96 +++ .../approvalflow/templates/admin.html | 50 ++ .../approvalflow/templates/base.html | 52 ++ .../approvalflow/templates/decision.html | 65 ++ .../approvalflow/templates/index.html | 50 ++ .../approvalflow/templates/login.html | 26 + .../approvalflow/templates/mail_status.html | 44 ++ .../templates/request_detail.html | 80 +++ .../approvalflow/templates/users.html | 76 +++ .../approvalflow/templates/workflow_edit.html | 99 +++ .../approvalflow/approvalflow/totp.py | 40 ++ OLD_VERSIONS/v9.2 patch/approvalflow.zip | Bin 0 -> 37742 bytes .../approvalflow/approvalflow/.env.example | 18 + .../approvalflow/approvalflow/Dockerfile | 9 + .../approvalflow/approvalflow/README.md | 116 ++++ .../approvalflow/approvalflow/app.py | 579 ++++++++++++++++++ .../approvalflow/approvalflow/db.py | 249 ++++++++ .../approvalflow/docker-compose.yml | 9 + .../approvalflow/approvalflow/mailer.py | 145 +++++ .../approvalflow/approvalflow/pipeline.py | 291 +++++++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 195 ++++++ .../approvalflow/templates/account.html | 96 +++ .../approvalflow/templates/admin.html | 50 ++ .../approvalflow/templates/base.html | 52 ++ .../approvalflow/templates/decision.html | 65 ++ .../approvalflow/templates/index.html | 50 ++ .../approvalflow/templates/login.html | 26 + .../approvalflow/templates/mail_status.html | 44 ++ .../templates/request_detail.html | 80 +++ .../approvalflow/templates/users.html | 76 +++ .../approvalflow/templates/workflow_edit.html | 99 +++ .../approvalflow/approvalflow/totp.py | 40 ++ OLD_VERSIONS/v9.3 patch/approvalflow.zip | Bin 0 -> 37771 bytes .../approvalflow/approvalflow/.env.example | 18 + .../approvalflow/approvalflow/Dockerfile | 9 + .../approvalflow/approvalflow/README.md | 116 ++++ .../approvalflow/approvalflow/app.py | 579 ++++++++++++++++++ .../approvalflow/approvalflow/db.py | 249 ++++++++ .../approvalflow/docker-compose.yml | 9 + .../approvalflow/approvalflow/mailer.py | 145 +++++ .../approvalflow/approvalflow/pipeline.py | 292 +++++++++ .../approvalflow/requirements.txt | 3 + .../approvalflow/static/style.css | 195 ++++++ .../approvalflow/templates/account.html | 96 +++ .../approvalflow/templates/admin.html | 50 ++ .../approvalflow/templates/base.html | 52 ++ .../approvalflow/templates/decision.html | 65 ++ .../approvalflow/templates/index.html | 50 ++ .../approvalflow/templates/login.html | 26 + .../approvalflow/templates/mail_status.html | 44 ++ .../templates/request_detail.html | 80 +++ .../approvalflow/templates/users.html | 76 +++ .../approvalflow/templates/workflow_edit.html | 99 +++ .../approvalflow/approvalflow/totp.py | 40 ++ README.md | 116 ++++ app.py | 579 ++++++++++++++++++ db.py | 249 ++++++++ docker-compose.yml | 9 + mailer.py | 145 +++++ pipeline.py | 292 +++++++++ requirements.txt | 3 + static/style.css | 195 ++++++ templates/account.html | 96 +++ templates/admin.html | 50 ++ templates/base.html | 52 ++ templates/decision.html | 65 ++ templates/index.html | 50 ++ templates/login.html | 26 + templates/mail_status.html | 44 ++ templates/request_detail.html | 80 +++ templates/users.html | 76 +++ templates/workflow_edit.html | 99 +++ totp.py | 40 ++ 270 files changed, 23789 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 OLD_VERSIONS/v1.0/approvalflow.zip create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/poller.py create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v1.0/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v2.0/approvalflow.zip create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/poller.py create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v2.0/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v3.0/approvalflow.zip create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v3.0/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow.zip create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/mail_status.html create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v4.0 w/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow.zip create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/mail_status.html create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v5.0 w/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow.zip create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/mail_status.html create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v6.0 w/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow.zip create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/mail_status.html create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v7.0 w/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow.zip create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/account.html create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/mail_status.html create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v8.0 w/approvalflow/approvalflow/totp.py create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow.zip create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/account.html create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/mail_status.html create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v9.0 final/approvalflow/approvalflow/totp.py create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow.zip create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/account.html create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/mail_status.html create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v9.1 patch/approvalflow/approvalflow/totp.py create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow.zip create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/account.html create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/mail_status.html create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v9.2 patch/approvalflow/approvalflow/totp.py create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow.zip create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/.env.example create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/Dockerfile create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/README.md create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/app.py create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/db.py create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/docker-compose.yml create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/mailer.py create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/pipeline.py create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/requirements.txt create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/static/style.css create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/account.html create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/admin.html create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/base.html create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/decision.html create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/index.html create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/login.html create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/mail_status.html create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/request_detail.html create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/users.html create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/templates/workflow_edit.html create mode 100644 OLD_VERSIONS/v9.3 patch/approvalflow/approvalflow/totp.py create mode 100644 README.md create mode 100644 app.py create mode 100644 db.py create mode 100644 docker-compose.yml create mode 100644 mailer.py create mode 100644 pipeline.py create mode 100644 requirements.txt create mode 100644 static/style.css create mode 100644 templates/account.html create mode 100644 templates/admin.html create mode 100644 templates/base.html create mode 100644 templates/decision.html create mode 100644 templates/index.html create mode 100644 templates/login.html create mode 100644 templates/mail_status.html create mode 100644 templates/request_detail.html create mode 100644 templates/users.html create mode 100644 templates/workflow_edit.html create mode 100644 totp.py 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 0000000000000000000000000000000000000000..c6cf082d3aac8a4ac9ad680c1346d8d817dbe1e4 GIT binary patch literal 23404 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 F`ycA;T7m!o literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ea8aeb52f5b95d100695d5ab9b0d19f446b7b1c0 GIT binary patch literal 25118 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 c_3JH!Tk42NkoP40&gYs9pd1A7fdqr~m)} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..55fe614d35c23a5071114d911b199fbd1c0814a0 GIT binary patch literal 22775 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/", 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 0000000000000000000000000000000000000000..d0a46cd84f9a4fc5d940edc9b1338540265a4fc9 GIT binary patch literal 25121 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

W@Ds5NR=q>q9BgbdQ;eVY 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 ZL%5}m408ZnhYaGw*~9+cGPJI*{s%gMjt~F< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a9414d496ca49d00951e35735c81841eb5d31afc GIT binary patch literal 26299 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e31272463fab2258168934edd26f02e6916c93c2 GIT binary patch literal 26994 zcmaI718{9?vo)OT*tTu#*tTsuJGQMI+qP}n-mz`l$)EGy`+w)H^WJ+`RnJ6H6NxI%OpY0HD`#rC)E~ z=HvbE(tnG1C!@fFJtDSL4G5WpY%==*%)2{aAF zki-j;pfLMHw?Qv)$l>#}Ztey^)=sh1b*G&gCDCyEM|H^Wp(foX@2T%^PrSstTtp-^ zsrbBc4X#=U@<9YhQA$j75btFY46TIhbZnz)o_?utcxYt3t%SO0fo!m}ys_u7KLbN5j}?Xf@il@Vuy`U6sURK-MG2k;t*9iA-s5tP8cjHjWb~DAr@`uB>l>wW0|>bU~48EJnJ zEO%NyN||Mu=naKTcY6|a8=nWg%Rs&83-6aJQM;5Ozea+fdM1sy(zlEj-^`=~!-#8K{GO-;Qy{ZBk?tz*k*h@XA zgWLme$FDrr3jsnm5jKZ%jaXyN1k%wT*d#E`nM-;^k!YUsGi8d(_!@2ujnFM=D1hby zID{kOh2 zL{he0(j9dJ-`B4k2}~@NRWWtlP8o96|?R8{qI9RftL83`oI^Se%alw?V8}5UtnIZMp)@fB9Q$`Ig zaer9kOL;Xi3u(nDfU(vzJo*Ghd@0QGvrufftV6?0q#4l$g$zqM-92vEH*bq4HLZ^{ zOlAaUw)q0o=Qaz@H%w@I6`g6iHEQWu+p z5{@#UmZH>Q?by~yO;Kqm4}&Zn%Q!yx=gtPWunA!zC1 z#u^1oFZ!&Az4XU;5&$S?%1d+DlDewN&Oo4tqmuoRpDbd)-v zXvvchi}V;(WV!}DF5>bkw^SdA3?-$(5==+lFcgEYXind#G=i4YUh9QHO~1HMlN`p4 zUz0DFX!eSCzwe1a}>LFMZ#fgViK_pu)Lt4uf}kHh^~dxkWW_6ahx}OUN*(AIL6L&&-J3p z&5tZGvih`<_!OGjG7ED~f`SFD^&tDbVjNnDkfwY`go|2swH+)EELLpDe3ch0uU8E( z2&2N4Uw~tEA9!M{R04JrZFI917H;**)rG{@)W)TV(ZrJIsk_OG8=WFiG@ee=872@O zr8H2?LX}3i&_e+qqoP-#*_ZA-?U1_y?Rg8@B4voM0x+ImvsLid~u55S`=%tjaqI z?QcY`5*v3x zGqMCtX{XTBZ8l9Nqgm%m&(978Zs%uFvDX2uNmClxHAF8~_Vx4R^cIQZVq5-v@y9CH zc|}`x`ucGOCadb;_aGI)Tt2Y|Muvsb`q9$A>~UBAR!ui#?6q@h3*A=Ho*9lf8>&=| zB+a_ly}7S)ZrITdf6S3~XpP&B#Tu=@-A%)kudR5{4jCyV`#aTNGfyaHb#YcJR9{Vn zeZ+~As{>T3@M+Y=_4Pu(aEL~c%({q5n4$)(#Xk|g(R~qDa zD)B-Nw5WJWOq8A-O6~Hfn-`Vc1UwaC(*WU;#j;uK;p2~h-Dwcyib`T=#HfBM&qU#o zV$sJm@H9bQrD=b9c&;VsNvUQ@VkIxOWC|f-t|3v@ore(Z34#?RNqH%l?5x64l1-_gYXk z?x^JE!efoNIpRf)&l8I(t6GMvEvQ}E>KF)aB?P1pu>l$;Pg2XOsOv^)nVGX=jCt@A zt8bF`wEznCor|eXL{o`<-?*MOfx6-E+E`v?P^+Q5vWXq^OP8gbS2@OlA}r!PtgKm= zdvuF-!@6unIH(E;Vlm=6R8{*f#MBPNpdmYz6X}Ee=db0CS&LhW0MP*G= zttmrN0>SI%8XHi1$8t{wp>Cq2eeGIF(}|J9bZWs873<)Q71@~iPfvNA6icA89@*E$ z*)+UJPZjN8G}}fjb18)421GDCOKU|{$q1q|2?2`7Xwrwoz}KkUZOJ}MW7w8#CLPt$ z_J23a?)0sbAwpeCt5ja&sujl~@T_ZAjm6o6w4hi;7!)pX*br>S@}GV@Zo}XD60@ZO zW+>v7D2^p4w}s)G35Q@r%^IWNROkg;V@m-?WgEh%Ilpc5Q526ro$fV#}DDpx2mz;QSv^%!v57SvCRcE?B{gKTl2sjH@r?%h`W;Ae19MMLQqSG0do+rZkM3 zZJVoc)t2*~uRPNU&cav0O4O6XfaZ2g4INKD)Ug8nrR3P3)J-yzoC3Re1-aYQX>#rG zxU8QN?MpDC{7;HOTx=U;P%EX58|XKqxVdHl9oS;n8wNIgn@wX#s2=CukO3@40ZAcO zAfgyR^CWj1Y{ZXKx0taZ_!ilIvMsF8r0T~Nn71W4CmS`UC^)eef{kjAc7uN%1?gSI z+#A+vbhK$`ZF#moGb>e5(m>SRU_4!)niA71>jL%~bFTR7b~STK+CAnBSm7XsDR zi>nuahJx*xYJGKOp>iaJ!fhPx_IbHAww1P`x-=wsxSI>FXVvLVe;Fi1slQY#CWpzD zSUFUJwqF$kSPh#`F5m?SRMDiQcyPkmWNSL0Z+Ka7IpV5wI3e!ylZgy#uoU3Pyct%1 z4aKi=yQTYy;^YKBsz%hnee!%$@cb$wV)0Tnk7WiR3h1!$z27@Q=Cqxed z_dKRM2BOO!@>bg4Hw9kBEy-329-3U-rEBmf4skA85$4&2$c<{8SuMu{ℜjTfd2* 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..4be3a953cda46cfda80cd0c89425b383391bb812 GIT binary patch literal 28521 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0dedd1f6da8d14e3987eba62579726e63bc761b9 GIT binary patch literal 34984 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 VM~97HF<}e<<`3NleZOD7{U5JEw?+T} literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b69f098440a80d818177b50e670ee3fe32562610 GIT binary patch literal 36071 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 wZehkA&7`H@v}dk7 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 Q@$tj_(a1F`@Yi4eAFQCh+5i9m literal 0 HcmV?d00001 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

+
+ +

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 0000000000000000000000000000000000000000..8cb7e2e3750dbe318816a29b7b55c3ac2b8aaaad GIT binary patch literal 37734 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 JE!zd+0RTgF9Z3KH literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9d2246971420fe229f70a12b363a310447d7dbc0 GIT binary patch literal 37742 zcmagG1CSNA%GQ-$MHvzRm@in>;;-TC z1`PlR^4CxQagzUI2IbEU1b}z(g%$!jTr$a@JBmL8_n*x;7~4CWI~ZFV+c-JWIk`Fg z!>sj}l7+F~znGPpkf51vZeU$v-l3AC9j}(8l?lC>m%3+4ypO!Qu!@H9*8=!!>i_aU z?9bGHX8b<~%0Ex-=%nvtZuno0fd2a#|8`{0{ZeaCtBn);k8vxaf8)r1TXS@Bw=$+P zbaecOBhLUT|IU#tjdh!YRz&aX8Uho9UY_-5X4skSf=d-KVH_*?jYfSIqDL$;XfUoDA@BDyyu*X(% zm`~Kj&ELcZ%iQEN7#ud9Alt9b1Ovnt=3)v$@TEmx4KqbA@ZFo+5!QfTUYw=7OS< z46_lyoCE7#CG2d@Vt&f1B*Z`CYm+qOiR-DGHAQG8p1}&aWn9C%TgkQ6T}=fxFX8Ik ziD;{VXA`V447{ZlcXp9xO9;?A#)>5&Iai%NqdJKcesNMuCQ!^o$mlZ@M^V}=`d^Ga z9nhG$6wT-sYjw;zpDw9nvDeJeF0+(Y6^Jha<(R?@yO^!7Pw(| z)DFFq$kswx>|;#Z}Ba3>e6cJy=b=5E;R=E0V+c!5YUASwjR zf!=ong&#s75KVshaiai!Bic}o5ksl$gu*k?_Rj1nJ`+s#d-wFKw_a<22SA?`5>N$> z#tN!hO|FR(bfFIJ9V7?Fq9^I=1bXMixSS1|NhZ?f;`Rrj7;GyzKxLojO=IixJ&Y$~ zlYLTSnJJ}iC3^_*S6C2eT_H!Q>h=)GdiwF3bRzIZ#scNm4Ya9T46xoM4RV0CK>|9ID=IndWw?P&+FK|cUMbZCyGDU0LS$Sah8Kt z+~Md9hv|6@rCH%S4mHs1wX`Wm5G$I)Zt;Woz1GT!cVrJIvf)Tk(8tmLV-i?*SmYjB zm(EGTgz~l$@uz$ccyTff#}p-yG#$zlpcE}wV40K;XZ`>$(vz7%zye~oLj+sGc4?RR z3GZ*$O1e5mSVIWSHkJ^y7xZF}XVP2tkzWm(fCW}65e!<6+x{{-x*e%$k->KPG4hyZ z21f?w*1i7dZq?=UziC~6L5WqBZ!e`V?b#W2Bzw%7$y=zn#oydU)VmTunoHl zMV2I<>ub&o^mkR1|Ew54QDwx{*kI{yumPzc#qF*+tM0kme$kZkK~jBvC$6X9!3#m3 z4VEIMt;QyREhGd@I^M%^gLYw4aTZc;Dxsx`$?sXQNrMcRZ&iwcq37+tg+7Q_ZDb3m z4@!^yMIm5H!>^iT2{`^xwX{!Comb~boNI>(;3jQQ_JAphB8mX&roA)Fi+I=lY6H}u zxP)`?6m4Hv4`w8)$W1E@^?7wDznd1)tN~$8HXL$%JunyG~8j=i99AVxxQAWO1M1HQHg z%i$ng+&o@>-Afw+i(ip}k8!Q52?N17kw~kdRg2o-xO>AGv&&etktP~L77BG8jVei{ zA}5iL5bTPOy4H^WM#AV%_DPbMXanR$licUvaQQs$kElbW(1+xz$Zb;O;SZz>^rEL}?Ae^W`ND68)63U+!tJQc=mmxWeY57Gk}e|hPCZY0zXpY&0EFUoIVWpkfhUJ@@i~U)WD~_wA;Jr zB+{!~lg+FDIwls$*{#4fAcwg4)>1;-WJ+6>C1uJ>{x3xABm0F~Gx^iF>? zu%z<8$#JZU zfk;RUf>G$ivn+9S>(uB2YNn46zFO4tn9J$Js?F%(-0K`P{~Gf$hu4-KD6(n4KKozX!!w8s{Rna z_&bd_CbKegXP_qG_JB&`d8cyzjm4^~2$I)SI3ztb0w6$|Ts2N3p_60sJ5|OF!FXdA ze|mRQtmuPF(*)D= z^ak>oksfhyEsSSXJ|zjfG=3u&FB15^0@qR-A0lO4eal$2y{Gwx(ibuBr?nlS&w~r|=)*aCqA|V?y!yvy>wOVaa+g{#Vw;`Aee4qQ`Vn(Si1+CHDBAAm z6rM8kt(CjCU?Jo)+dmAWbB6T3$g)_tUk)GIwuHrbESk< za&3$=*dQA_i|@oCSNf^Wyh9f)QMP!{?PTIt*WyHBI}hnw4vk4+V>Hj=%(pNwCc}u! z+U9tG;ozl%kuy`2_A6v)gjVTWKo3b)d5!f-YxWCP2V5*L6Y&FNHVhx(B7LktJra8t z>l^%U#JQ7rrKM4607?f70KkU}0D$(-_*GFvKv+hE&f4f7P}ArSUj8?jIaz(vVSO02 zdqnAC^eQOnHL+EqIKCt+k9bUhb~TEUJ&{@;l4h)mpC&?E+#qw($YU;J%bCeNNaqQV z%NvVNqQ`L>h=i2#vUYUf-;*}_RNpOcuLik1CXsV1;I3#Gy7y6f4mGq;jbHef00}(ikuOwI^K-6{8 zJ8q(CprW7Ki+*XU1w)3U#OC$~;f90H%A{fsVZH3^AbPYVZd5OmwLr8OAXoitZ<}CW z;Z4J;K0eGx_y8aW$s*(svnn-ARJYZ}4Ad)FOC!YYqALg>9ki423k%y1tqb`Hr@6l) z?|^8#uxg7P5E_aPAqWy$$G&&{L=a;if0qP=u(i!q?F=HKlJ#ET0zUZJ4Zaq9BOEp} znh;jZUCtYJn{&z^5?4tS>3EXf?lQ_*Ur|^JyP^%+FM;9L7v7P{j%7fOVdF=s9*~CKZJia&E2EjTG0=3NMVAj33ih09RkD1i?XKCrnS4(0TGgE>E0WOQvwsAz34!1z(6ROh5?)0Q%;t@$Wv*yWB+-@=NRs>Kwy zWQKA|zvx<;yb6nhr#Ye|IU#RTy7w!Hq3-Y%Q6K7g&IO8c_c&LW%G1&vTBzMST2NQ% zQuU6NN{yJMU!hiJ-_P#$v?k|OW6J?bVS)Sh1ks1IOqV|~&KjYDNm+QoD2X`=1bs73 zs*BW$57iVQ2r=;Wffrl)hc!-j1)j)|qUBRWvIrMKq16tO6siN7GH95|O0dpw9T_9k zO{^c=i5(oOEscbh7?#9|s4Ub^=qTvIJp30J8V|ApKX&yi2L;oe^m~|J5us(iy4BE5 z(nj4@E)Q&G0TE1*B?4?E#+D-CaX407wRA6%x&x`18Vsmk@aLx?jT^ss=6yim<;VLu z**ZenabcmA;Z)y3Ua4-PU^jOW-l-zdj(1|I-`~GdpKH;8FIxUNwswjjLH>^4k zrWPuX3oEuP1a+YS_w#B$mJe+aU?b^i?``aMXqB3GXYpWqBi1Bs(^ zfPB^b2xWghc-F4>P#)3YaG)1<&gr;(hW~sHuMf?9+MGk-JkRgXud74!Sp4lu-l}e& z^q3?!=tBjVQwS^@+G?O+d}|T%!U)Kr=xSPY`Z^=P2MP#H4*%AX@EtaAAbI`;@g+bg zP>k+{2Tko%CzTb;0!5}7z)RoSO$`qPsJ;i_K34K(R?tPqL)jpe<^Y~r5J0}lb!Aj4 zysLh2Vum~d{4>+uw&Ll7E#{aHaxO6PyNL~zKk#HjLfn1WB!DTS&_KQQyM_(P|5$W8 zF2)?ai;|%n3Y#%#n}~hioTl8a(N$|oK}&%e(0+k)o@pgHGkjDqk8m2{EIOJSAH0Br zt~AH5z4+=%YJ`R?DZ;=aDS)Ic%GSG=VhW2DFTS&C6tb(8Bvi(-yOU@=q*)zIgIZk?Zjp&LzT(XVfSqeG# z4t$&Od%elHv=H-M?Su9u{lCaPGu`=T!ddh1`gm!tiwNh~7>Z1Vi#BumE@Z`^Iv7^T zs9{Xa9W0LuC*A~DWeSlxzfWt*&$k6~>MYFfo_1%xCGZ>?pMBp)TpDj6U0x8s6@#}2 z&!73eHK*uwTO7^S`M$<;LWx;kJo3)JwcAU+L!AodPV(G3#Kso4 zzl%J~MDSvMx^(zrm#BYkKD&y_u79mwi?jZc-R}JQUEPJ3PVmYa_}HwJOFk*z4ymDw za94=iS6T$gtu6dg9X7cfB3ROSOhjItzg z>%>jML6wtUv-OMXFk-HSFPO-luz^mjXi5I1zx8Q{%Pu@X%7b=E(MMFxIJ}3e?PBuO zK9e}5T3?=?cHM%5P~%w5QHIONBNi=!M>1Zb!2H2j*D4zNh7A(v*YKmh@s;CtH;%kb zbuF?LG5Mf~nv8ZMksjzbl2CV5O4GCJY!wn66&-JnO(D3V%t(Y zga}PVd4L=Hnq$in2rDRooKg-b`80cfFt#ShS=ln0=^aiCl^hapZ{8@+^RTIK#naD4 z0tXd5GD3@trcP6DqF|cVkWdT9df8>QquoR`-&rRZRlf=dW=_(l!QVziu*D8Ow*fCEA&nj)NIx!-pOMjlUR!FGkzb%S7Z(&kj+aU9!_==RNCj?R@+}3r@9J=lw%&0~bFwb#nd~$ed@Xo1sY_3Fyoz913 zu@o1YF6L@2Y0e3WYHTF2otGVtB~0y@j5@iYc7nXy@EB?tRBpQ{=uE10XF zFyo5WQj3OoAMT}#ja-C1FV>t)X~rdZ8ST#;)#+i&a##2s#<=wv(pS(!lh-A^ncZgm z9MS5wt_OoBf8pOqwnBUgwUg$eM|;B_U1G)dq_32waCEo?CP=qR@eTSn`?}D-)Y?#E zF=K@U0EkBaH|)z=-`vXBfzHnTAM9&mN!bGbUpp3AKhJHj+fcvwCO_7Y1xrLI1KowHyU;iPEZO(kG!d!kZwPI*vcyM0F^>BgqQnFbSv9 z7+LZCdbBORvznL`WMa$^@H;h6DRWQPOrg$uETA$VgBo+7G{k)7iQRSdc5!EYHFmqz zuT?q6zwHS_bvv4qrZvxbMG(=f(7>?i-R0D#7_1N7Fqt;{^lVhNXOZPzdC5bUEi%9hj0n*xTo7|xtw58Q zvUHOKtQNQ*CEn7jkgQuvX`{@LG#OZKb|Dv0X?T$acB$@IvsBV0JmI=LO+1`}Rlh~8rw--EfUR@95cqMJ2Iy$Gq4^Q$WO5_5vB&VeM+U9Z$Lf|bi zQQ{oZL#&3j;smBaZQ;+FsK^y?YTTm^dGc=MJ*sz^I_nD$>8bpPo3Oo|cgWo5^E60x zTpU1B@OZY8Q$yl$w0_`$0629*yj}h@EJ&VTHqk+zKS1PN+JGdACLvBAE{$!B;v;Fm z6nRO13iEyBda@v|NgFB1``u5&0iIJK=6Z9bbR2Q0?6e?ooJkgl|K$Hjz-(^)wx=-0 z`+1V*E{+B!Rw*BW3k6K!`p)h{Q-#V1StDg(s}`~L{4y7w(#3n~_H7{z>M-t(Fhmi_ znD1zvkjSU>*5-BycxphKfax*pU!YTqJv5Hv@C6hEFCScQsVaV!V%(pKi(;&C$Q)Ei zB`*@Ym&d&;Ifytt!`V_!p}V(1Z&&GPF;OzuGlCN~UCD9ckslK92HVcDzIYW9>nYww zW6~z5LaBt}CDLtOqGzk?kd$MBWPQ>9l`k3m5g(mC5x;D2eotUVA8*qQODxanFqw2m%eMEM!w3p-MfFwk}ky@UQz`zC`-aOwu+VO-08m; zGiN+VXDiww6ZsfS=>288*^YO7vD{FhJFEQB6IA8P-y?J+WDin`?@TF@@sy+${TDNHKOts*+)(Xt0Cu%gEFsiy1*Zi8PnfjuKJD8DS}*h zr$8)>gKo_xv)pZ+&E2lR0dqcy#3YjR*SYFEx0EBNfN0+RT2rohesjiw4Mp{E_mkcS z!D0CsyG^0RP$O=pwqwK53fKWy(-Md%8XN7X8hrA8U@1?IOm-PDu|X_eGTY%UE4uH!pw2}c%YvnEthje%h1+oWqH71(>&XLB59MhW(> zP&pN&*0bZeM^xlr*8E^WEVr`D<|Zrqf(DIowSl);$=kN!>-gwa6;+Rj{$s^y4zZ!|< zo7eSCB9_vWstTkx)O=&R25|M?%WK(GCU)qDlS!c7Esjffy3kMf#MiW%#=+-Zl1Jg4}yh%PtIob9zxZ~etu25e>u?7CsG$Wmr5k&Hx)@rMUE=@iCx(ZHT{D3Ux% zR~E`)fk$t|a=IUFHgXKXY9d!1S}!@Ol~XJ_uLqctj5PbYT zsM20K!nB+4U!DZNauuIrH0vLF z^i8VzYIxNr-mb7sltDiquQZOw^{K#A>}taY|1?CfOc#2Lbz^%|+XG+3_g}7_Qr^Lm zpkHa*&`XF_fVe{y?O!cM1Ri1@*UTe$-Q4lmP;`OC!EyRch;{88BR?|xPIzQ zN?8IB2yCtOn$=6B-{CDkq(v^1) zo4|nmGPlWixZ1mKKaiMa#A^BFbn#H?=*_`PB#CsRJ7JHg6v|coU1Rn_eQQGm3qqV# zRR<)8zb{()ux`HQkrgc6`^$ao9gDWfb8P=|Lh)U{0wy9TRc19I&i zJjzoRE0O~#S>-1X9c9$9ZJV#-q3#l*ZHj$36oU+~Xu%~8H#ai{;7Ol=xa3#9^l=P` zmX%FWcUBW$gY16BaM} ztPGO}QL(pG&T0Kvfh$UX+UERWLrH9boTHoVd6oTxdDv#1R(5G#A69gKFcC7#$;Q;` z(r??R`;#f;d;my0^22aUK^y+Sh14Aj*n`S6#>ASnX=P}y+3lU4b>VtkXhB=@qW(o~{mJIs zLvzhXAJm*tG^^%G6_Hnz0%?`}UTu5adN-WA`F<*>uNrx~`%&RfC~= z%HoRO@TP^`FsSqvnA`-+3E|SQKC44^4you4Y;LnCr5UUETb2oc(ejWTFu>(il`P@$ z`77qx_`;}?SsVLvsoHs$*vJeN;T_g~yZEGPpN2#Z91U<2qjK>w-etnZyD#MSes=!n zIb9#=7QJ6GOg3;1#e%tFFS8plMB+4y=;C43rGjv6dza8LBz;(d5*9~aY$9CJ^;vd9 zi|%SXd>#p_V@o_U6dBMB;1o;rfFIkWPtK%IFN8_WUj9{!Wn<$gvPTW_7wT>aGH^c= zV3%Ji-{3QmA4-p|E}UGLNi$Uh(T5zW*~-9b2eEzH#oBTCi7~Qc2OKo`8uOMb5i&=F}!8Q{a zvBwPxD6`3t3Sy6DXAU}XN5211cKt0Xqe0wY`~4>+zySgPp#O7P5dKG6F!@8!{8MD?}k{>F_zB!4yK>v`8m z+W8hdwsILLUT$Q6ijSDo83R@_x|M>IT>5qS?N0&JN}mdj^+QQb@9w`6$ zq5o~r)z-oCFO8tCv5~nGote{rXJybo{8;&4Idaj@^%!h+B=2W6gsxgvQ6rfPG0orD zBlb~7>>>U}+h_=-CK57o?5pOADZ#6NPsC5q&r%K%M!E}F(L7_^W)$_}s#!|xXGe9p zGucrNM4D1!k@L$cWO&=W?d~*7$+XqM%iTni8yjJ(nnj<|C($dmASy9~7d)4ydrztt z)}CEu7dju`J>SSZqf3@b<7dvl8=n#@eFmZ626=8LZ(oC1<$Lcp>c>(QlW480nY3Cg zD;9^F(V5FEtWD(X{jMlr6YAVWme$&z$cXwLdiA~(OZ=J5v=TLBKv~_9g7{J@0 ziI8S>jBnUv9oiZzz>y`-LtbXpdJ-E2V=DIdp;-jiH!Ya((trn+WkH)O^GP8RHj_2W|(fV(f3T=_M68Ajd{udmBSU7tTo9m+IDRxO>S(xSmY>)%cuGF*P1&UwzM3aU*!MMV@GOu zQnkWq0Z1`S3OTIJ;vglLnyPTqNy_mGr?gUZ#)i8PW%Qn<|sydP^9QFm+1P z9Fj1@a#JVyr~8Nr_}!-l#{lMcOL{!Clwj!OW>cU!sh8MrluYnsu&TW)m_$sMPzi`q zvr`P7H)@9Fr`cA!;Qpmy3dyebF8rqdUgS@iYNVT?LNYp!5 zioUVIYkR|E*kKKUT1L~SS3eQ8G1Qv{();;0NcaH3-Ez@dLc}?Z944_z1OvFY=QdsE z7f51($*{)~Tw1+mFzE*NQyi9gLgcc2yn3pLKs5OFdyL?+P&SLF9;n+7IvQ`n6HE23 zKwG2NP=Cm0r0UgP9xg~rn%OI$FVzi_snQ{grW;}+n1UviP<{I%)EW`q5gZ;r~4yHEl{IBFVy4lk*g9>J#TB{ztYpzK${k3^E2L_3}Godf56 z_%e(jts*mG*JL@BCPSCeO6^((z_IN39DD^{Iymi@%K2=G;553}3E}zCEU2P0`I;P)IFe_D{J`X;xx;f&`}l z)3wZ0`^OjouA3vXwO+9T#ChG_+ZB`e#4ITH!pU4&?S0cWVsc$k#XT+$x+~c;kF`0r zO&_vAioh+sdi}%|KSSgE6b>LLjB&#eURr5Gu(fjB@gXr|6!N8PY2|&)h2e8$wVpf_ z5Y64*Cj9N_NF>yOZ9AX0o97`T2}>r7LxR&f7;0Im8wP*9JvB6gzh&?4^ZYy3vCgv9 zpcA3T3*Tce7=XcSc!~B55&ul`h2O04;6;6PIqBy;a*SIEUI~}BE$2Gva|q2nGQP-> z+3&ID>|2bmN7dG54xcx$cU8nr_Xp1}2wQ!#^FXIoD;&L%<(+uScUF5bV1(_L5i!_( z)+@tJ4;jP4RJLnDhHHb z4-5*U>1hzv^(i0KtRF&PTe^=!7ooKhFc4qbL84QHFS8}VIiMUcPgq;SHJVq+t2vxl zjRMr9iv~|l!mA`QsaS-1(>!A(TQ^0QgvapfN<=?d=2OW>3Wd`~bv=ojN&Qgr-bcwG znf4fry1tTCJ_i2C;Gi?FncTF*sWc{5!6N(gI_Z9uC*<$kaK!Sr^Ya#k(!R`G#@TRVs9ydJov z?%}Cr964-t92qO~Lf~cdcy!Q`ZkTd?@T3bE7WS038?!FFav;{j_dkHlUo};8M;sFR z4>x#%001ET&ox!w$lBcI|E{G3|5{74RHtkf7!bOzm7)S9Bb7=0K+x+`sB+PTgU9nm zl94^xZE%0AOc$$^4GP@^aYGs&nxfTYzY_0wV>^yiWyP}MKH9T-$5*~ExR6H4TB{|N7&`*@w z_dcMmO%w>~yAwO7pOQc4XrjzpDZ&4P!`P1b@Q z`jVUWF|=#(_#O!e)vN=FQLxCxoAhxB8s`yDH;Vc;TPv#SZje(%QTh-=R~ac^7lP2Cgd*@fO(yxox+8hwN=r10oMCF-9%I8cI)WGh{B z?*x?*&96oEJjK^jQL8RzXF@H771!2{W&6r!F|ujP20?#fbZ3gCg{+N&zIi!pw5$f~ zZ}nnYfMl@O>G>oP-C-u13yy^t%vWaB6rA>!o1Nljie2YdWon-&C~e!LvsrD?b=UR; z3CK0=+2>|3JyK?aqQB*32EBdXtmV?VF>~w%X3zvrOr`jY%epZ}JuLcdP)^r}w@vjWGvGo!^&F-M*SLQZBspI869PjSiF`%tjNg=mPjiW8d z2zq=-cWyHy58%V$gpauREZ1p%3x5Iqy@~*DaH)mYZ)8RX1OSlqXMf875@lAlrvEd_ zaF><;ou)2Yc|GP29rxa@!BD4y7B}Ak1tE#&4`3%L1l2((&m5GX@fyIh)l{LrMf4)n z?dL#mhTe?eEt;NeoBZ`s3sfhhzXniwBv1*y5Ycybv{xITv3i7^D@=NFAo&DV=&#G? z)9-nuknUfxJnil|wfMuhWkG;n={F<4AuOXF7qx~vZ1f&slJu%hdS&wuvsPPmXF$7; z_NR@;UuWu%p_LFZ8E0wrqay`GCBq>6nf1V%w=(=&Lb{Vcy5FK zNn;@ejYf2FfI8H3f?~Fj?OZqnV7su(KsGiJoz+V>DI0FNqHZZo^{L`unt)j?SWj$Q z0x~$$RgoLfPY^1BXdEleM$=$M{DNWaM&V->uqxbt1dABAEf8`*5neQDkwx~10?lP;cGtpVaRDZAm9vDb3>c9tJSmgHGJeYE90@#s_C!cL z0d@15ruu_44xtheQYAh4dA15WkKhzJLe}~5*!DT{QX^Fm7*nEe_UX}bx%-sOR0Hnh zz4~<1V(-DdpO5}DR#mpwhW5~r8PMp(>)+prM~iI`C7J=<^G>+Z+n2iNBes8kh<~N~ zL}ZRnfj{Zq3H*Qk5Y26jjNSg9={{IxDrTJxq5GW@3?{0vcUTf_R0ge<^PyQ}4kscL zMiNI;nnHn8>>L9c*gK3@Kf7=Mf9&pyh4#uir`r=LotD*u?eOO$Ap ziYDVvxd2P+6P6uSrvgGA24ay~j}nrxlR+ENB{RWrP5X_=vlv|B=Kca{|dGt}tZ zR9x^Zhc$CjBDJ+VNjQK^m)uK;_8wWhyP8H|^L1cHE{EC?F%Fx?fO~c2Ti@OtB|6`N zS$0|S)JIMij&$1V0@juo+@<4sae&C67s`XAM2bk|MMpfGh_jgm&nxim^Fp>s0rKcF zpI=bXl=2mA`jVVBOiHu4K08;6k$PelR-rvik`En6O3wpnCq^{lJpBO7_8DTfsr2+D z(RkYJu*y7n-s(Zuggv5A z`b9#ReP@ul&1a2as%VL;_r2UPuEO=^KelA}TnQdk(y?oKkf*3kaQk(zAJ}hpi8p-r zyss=akI7EE84LD+V~OqzvW|>HdiUit8*|*C5R@?Q5-=Q5mnZaGs>F!!Mh*spT+)T- zlPpOkTWz%AN%$gz?sQ}#7P4I2#S_VzS_q-hTl|1!Y%5b+ z7Nt~@NX`d;N>o#oRc+nSG~RBD1lBMmmWR;Y2P!E{?KozlVKWLgr9bCswq&9O|UuwMAoF4>5j2l&>m4QjpQ&= zpf+GRP?DtuM9u4{IZ7`x~DcWhedU24^t>9ZmJiA{z-P>lRPL$SX)|GIx| z_j7jqJ3=8A{(Y%Xk+NB5K-hdo0Y(i$CTLohQLFa@knjhRL6?Z6gO$Wlw<1+Ily`w8 z=7*m{ej>?=H`1)nnvp~qbH6{D3SDR1v5hSvgD?=`nDUATXYj$X9zi%}dxhM3!Ery9 z{e?41t&9Beevf{YbYDEF?4zwRU9J4Z*kajiP`gwMpRkh9<+cjl@T>q&oq79BQAsd{ zI>(*M1(&~$+77Enxa<{@TMpI9T-v8e@MH&SxzEgy&2MOvehybQ%~Ps4z$L`yn7d-% z9U~sW#l)lj^0REN>axv zgu5EfO{h}7p^nzvXyx#EdcU_maOusFH~;}-ACC(Praqx5QZPqko0(<+lm6hiVv`>K>;;_6=i4{7 z$D(lHTjozkC{XCL*^~KcwskM5WJ#be7;w5;Lb0b{md2>;-5FA3Dj6*dW=Q#!%%&!% zbwk@REp40ulB;WLE0>Lg_evJaLMviOxgd25lP{z$GQ@h`aDEf%Mf6^WmT#u!eoq4y zU385@IgV5M7*J@LPPbMV9k~cviLTu<)6w*y)0UhRLD;t4@7b(hoYl!8p>1^Pi>2j+ zu|clI0j?}(d7xYUX|DkUcU(-gUY2LK@c&k?6@XlUzf^FNjnH);Ou zzS=4^UE4qP(08VU!;B&wpc&QEQjMe8uhKFE=zNK6(~(~yX()+EJt4x-2=I;Ysnxs9 zCjpmoOwnpW=65&AUQWmR=H~U)$vQc|Ee&d!KLJ@tjDzM4K=uai2CskG!~yV66V9Qm zR7E2z1q&$2iKyMJW4Z5M?0i~e&fOBImKsRDeTVS+(t4!pa>+t|&85q!JNw2Wd$hf) zK52peaK`)IBzpwy+TdJD2Ef5yniw0~JQn5H4SWG8C|B18h8UB)2sk(s$_|@CKan@*5{|J8e7wKr{QRkC zn1hx3we;tK0U}bGrOUg>z>dz7AsG@E8bh**HPk9L%qXyE{E*C=9{%l7x0|RUPo6l%V*}UQpHEen+yst zMWcgviReNOY^x@|DtdV(L2|;lLr#*KaXmt5JsW=AG1^K$z4(*Fi+~oC>lR#_cNB2$ z+%I=R=f>?xrPou>K=dQ2cQ6usKkGrXQ}OXKx92B@Cd!vi)2$C>C}zEeMJSJ$Pi;FV zZa3!-o^lIN)SXFVb(LKaQ94)|mdC(lL)E!S3Zf;)r3691TjWe404niG%6ym9@-dBY zRv;1cO&<32Uq4${wZJEOB~v^uTds{kvf-7Qd+D-9lLU*66*kW}b~Y3&7Shiu9&5^; zk}!|Os8{n;)AbC8mm$`I+h?iVR0V0lF=k=&oy|g^T%?gphyZ*mdgTh0)#aydYy@mL zP@yws0&_HNnk2e0>C`RmPhW=W+RX`1HgJBB9RfvXx%_6onOFoEwmsi~?;y6~tm2Bm*kN++WB(q|a;H7D#5#jnIoTx_@ znMZ--#Fbl^l0cn_TGju&;toedTNIK}R82pHMnAS8Z4rnQthVOR4XG4t!z=JUWV#jbZgP3lQE!EXK}yr2i#F8XtbAG$HsqfmH4Wg+7qy`7AjKcB z8j=un9Alil*W-S@Eis*Wp8tHsYkx19CR@&M-_ej-EOk-prZ-x*c7Blloegmji;D<( z_-^4GUm&q^8G>Ft+Aso`XjUvAEQ=7;G%h{BATgATNvf4%J{(?5oT*3HM#N5NS_jEz zky=5G93wVTuDfS|H$nzPGU41>RyEinTOqQmuF9vO)Yk$V;tKKCGuf2&2 zyR#JEf3Kr)$i?vyZPmr}<<9L#RpF;?1;A+>x$!o%IVWM+YAzabg)a`}D{R0DuCN)` z!yPB9#BNiILYgqJ=%j08 z?DPly{0)2lLr*75ZOUf<4>38W1f!~#_0#^5A=EKCE(qM%)DvJ8=9WJ|()M7$v-Av)&Ird>;;&9W+@5kXEt zV!3W?@SqAclz0I|P_<>6xK#|@N$B6KPi4`ZvIu)%04#t4Z?(*IK0p<^o@~JCTJ8?!`yE^YiZ0g~wFEWr>+mo_ojgcCdXFlN(px^Q zlG~G^z1vZ~0K=ln>Dqb}nH@az_XYyk7s2o`-FGzl(!e0Zr@9m1As8=9$n#Dc-LvdC zDFDDZ)lzSzv51PHwkDO;!_$`?_pW28KKW|0eDH;&beMc^PU|W#v^5AT@*P)@-SUVZ zS)I%%Sy^V9i$cAE37H2CiMg|UP8TTgc#&;N3&HrNqQ&N9FxBhT5!Vwi)#Q6Z{p3i^ z|Hs%nKX(Fl?PIZR+jchE*fux*#I~)CZQHhO+s4MWnx~y_`}Uc((;v=%aL<|RT)G>< zDI>+on#yKJr6CF{N~&WX5TB#6N`_f+zW)g1Jcg)Xc!C+`B0u z?7WrrsqCDlAn@&`8<^lG$S0+$!5B?@Rj~CJeS$wX^Le^f7NK%Ju)*cIW!jHnL#8=5 zkZd;{zmGJ9K`dBd+r$Rg7dO?~MPS>T#>)2gAZ`=pH7nkoj@5#P4`-KU_LB=aG$<-- zQ@_A!4S3!jSR`EtgRIH5dhM;4j6+v(Q&U4`7B#)PiZzYt&m5?@jm!)n^jR(a7xaNV z-dCp&q#VMSg--)Wri+EeO`lNss?ye=9;eTaZNB<$xQ|8ZGo{PXj|XKWcgRY>hs>y` z*U;gUnSM>HEi7dpuWS{z4RPI@c(Hibq5f0McV_!zUsraq<;pq5AlMj& z1SDS}x~xZ!4lQGHpq&y$DDe#+KOmks2X8gtLAZPy`_Di*gj=U@$r^(TIXoOX7|tmp zOYUg>aPS(}+>=?b`*xuRWy3GAwy^4cp)rj$mm(hMIu^QUvRPN)g zzoH*Ly;4H#S>PSE3WK+LI%j(Yah1DC$>ieO`5D)*2qO}A?s@0XLIxeK+KTIES5JYY ziPRX-hkH@`sBIXjcZB$_+-iEyqQ3z_vvA_y5TCIB_a!+cxTNdwvn01+fq=;V-aNzmi(s_U3% zL;rOgW{aODIE54%zt#YItR;=Tz8+@~PT!;NCW!yx8im=jTYgak*fn|HdVvSC9D%x> zqR)?6(iJ6$4ep3X}yoR>-6^ zEpi6C2Pm!5fS$C#sB6+@+YkVbGKMOybzS5&r$dZoR}7)WP$ zD3+Ov9Oxopr^3z@Wx-0{njz`w14?_kH~2FM-LS z27M={U#BJ0`>B)tDjP)N&x-L_PX7emuJzH4|0yTRPGr3TTl7CnD{?3$D}`uyjBbqD zZg^JKf7mR#8mCvffod;+4qfAJI)+xoTGTO?q`!&Rs?_@^6jseqh!C3?RSR*632g3< zr`m|k^D1-)O9BSvIeeQ;%^U~^a^Equ9-_*^SP#5hG|tSd8=3N&i+yD~p?C8qd#~$Y z9r8u|!%6=D1{bf3TjC(jM%Ad2d;xWA$?Yck5h27;IWQjLrXpnEH0Sj9uk6T3PRaNB z&H^2Y?dN=>;eUYSi&ZhajC&{NPFGW~V}`P$Q&(1XIU2tNcN1JHOOL8&LXGHS2>Xiq zWfBU}7HYSj=ary7BqaWn`=F4HsE`o3@8CqK1d)%!w?`m*cw~T0H6W33$@#DC{toQJ zK824@2#Z!$de}z&CxO%IRo z*zzA^5KrI)1JBPsL-_Fu5&ggKGed(PPv!q<-}#S2=s)vcU0*|LTQ-MmXg`u89*HyS zk_$yq064egc#HszA(GOBga7PkLMEBoe`H4)Y1eIko^1b>T}3yP=u6|&`mD=sdt`E# zZpUd?pUa0pCG@9mVKXb*yMo0o0VYjT^D#$e%`20+os+LR%_kVMx+ zjaC)b7@xC2sA-PsotKR|lB=guV`9kLdRAtkEBrU(^*^( zf+#6(b}toFV@*-6>5Q*0#ge0v8N{Whi+9pJo31Oyw6lBgG46lSl@OeCtq~NBrPi`x z7`i7>#OUu1RjBO|;St|+){$~cCvULqUv!c=ey@@^+*C_vs!^|ZzUm@{yBHTRP>_+>6?mAqE8`40fOX;Djmo))9MT3fxkZT%X<_%-3SM%E1YlI zUSH_+#j%6Xt!>VTQJb9p3;LloJT%|rY?~di`TKz=2Hc;>FPrr;{8Y05*lBh%=IYre zf0(~oD?U$WKE7roV3UmoN6KtTTiTj8x#m35{NE|mroHIWIWjVpCY0`GJI;>fD^(_m z=0ldHro;!3zgiO644bKPpPEhKwj1$H6ui`R-(Ao53!iL2lItQnEzFYiyH2UtYM;J$ zk@3)ZHUtxUYQ}Cy@9Quw>D;_I>XC?k=}V3`%4TDXFENC$G(j<~O4Xacwa=6#bR!BmtIA~;9KAui#gar%*E(F z-9wJ*i>T(|I8Y%qFeVMF*u)bww+?K)VnJXCX0Mgawj^%S|2@^Icj%HTltLc`O6OF~ zguz-6ohG^^8JtQ+o!b>)+sHM(zhnzb+E*(&&=g(tIPz8If7I~reO!E?UlgUs{Mqj#8Hxs&h*p9Wou!OO;d10*73Ti~WP?XzL#)XXVSZ$h);nZ#=L9TW76JKV zhbYX_qr-vq{eH#tG8~3hWp-a-6XR!jRXk(gqD+-DrqVSNeVM)nEWPvQ8auwF8^fc; zYj#Ljb;#TyLZk`ni{@z`(J>9>l{Pj*olBviS2np*NhW|BH(EhM!xZ<%>oUEFM@YkWJDMrfSrAa>Q~tgeBO+LDLFMMRccw1vEeG|Gs0Di1P*f|+p1-uEF4um?-j?k z-tH#=Da2j{+F}h=gpyex)yvZ+p|KZKj1H(P0wBlFM!;WaV)xnZ`?$uN#?vc?M0!+O zZCAx4av7@Elt*DE#L^kWo~&(xM)`dSNKqSjp*8*u<-0NPCV278ksY;~Dx54aMggCD zx*!%}o`zqK_C}MNt)iu}&220YS<~dW7V#X&4^O}K?=4nHLsK#^x};f` zMv^#fagzlD+YvQx2(EIEx|8cNUAH5f6HkQFc+2i6>ltQ0mRN*Ku_;d6b~V&`%MBwB z%wY$$8$V-|eQf>;u_b%Mlh9vp*flRQ!0NkNj`;1|Uw4#iqP`H8A<~)Kx0ScJBnHC9eagQw*H}7P4sWvB%};!NJ8$ zr{*eXo99n*P27XnHf%R0d@`&Y2VtQR->?~7R)DJLt^cfSyw~nWEem23HYoq}Zv;I$ zGdL}2gbx?&skV<#>-hkbGE?4(VP6;;ME!4dnZWv$kOo^HZ;i4&Ld>TPI1P_&h3x5w ztVjOo2EBs{2=3hiVx;$%_2=7x1L}A~uZKup_IXD5J~B|G*?kGZhVM0E;?p>qL%IO zt6zKu5lG*y43PC?PrsL};3hqsXS+ukgR5ydutglQR`0#S-s~P6d(gL5*w}V^tXSz< z5FDiOgx|U0<~7fqP`SjCu4jJGzWt9{ra7-m9c#pIwg0$DY<|Q-?El733;-J!27sHv zkG=Z;u%CNC{6C5n03~b7A8GLGO3$9nzYvKB;A8}AJZZ@bC7VGfNBxe5_I?&N%1xS; zb+mK6Sd4815@?-8z-Pi)yMEnC)i$M^KP7KJa2lPmpSlCx6yOBll_+G-#|v&|h;r zk;h`rG2cZW&J7LPPG#Cc8#)S9PM=glT{2$hQEbZ{pJ%%1M#-aLyQvMuL0z6u8lQW%S9e>R z|NWWZ!c`=L07c%T2E@kEZ*lx+X*BJqW=O)Sw@o^{Tc^Gn z#ns>4>G|Qob2u4*PnY8Ef=^bHU^V6Q{@A0@mtgkHLMuhU(hapA1YQrVZThMTb{Sp= z(B!!_OgkcvP&@6MtS?kR^r5a-@t%2D$gHph&pGL&b8*S%)vl$24yUEiTdTQEnFw%v zIhyZ~PWjV1%Mj0e$_+RQ*6c#uzW>%zaZvNDUN!qg zF`38kjeVx(Yf>H4$j>5a4HC)64z92u{X(#5JkqyNzY8IxN{oW30|a zi8Zg^=Zc2h@Aznq&FGF&lFkcIV;BmP@XA&v1!j1W=!BbDicn8jfh63P`#e3|dT)VT z7(1OtsKmO*LSA_C&Rl)Q_u|GFe6D$cXB!P?majSBl^((DY^+)sn@AX2{5HRvbI?ex zNWw{3Lzn-Q{u=u_Yiqa%_k4o)dEd~D{XV*#b8=vN+>wjkmho+z2I{==inK*2UIF|I+mX^v&Jd)EZF8~0F)>A9|1zIWAyFHG+)^AYin1Q!FS05+5Uwo=!5xv_CtL zgA53La-Ljq7{6{3!Kt~|*9>!gfZ^G1Ab)T}X}uUrpk0FHw;!xj1)e8Ipiefb-7Jg8 zeP`!d=vW6s-yh;n%0?4D04V5k%OzMqse%XntqoEj!3m1^E(q(V`_+u65tiC|15G?p zCNF}r^W||}0Xg^AWq=EEw3X4HcpV@etG(AoPuYPLxJcFpQagK(-<1%+JGpFL^~^g4 z6FsF5If^Xw&xcvpq_=2~E7v!%NT(NUGz490FO_=%>=zocc7?q5yec^DsXBrd6U_#G0zv|I1)~afo{-SUzB$^| zLil?R8HElRihEm}G4jjyA)4Fb#1frQaE+ zUBOy#Ju`G4b2h@Is!`?QkHzW4wWnNGZrTXtIj*$C;G8hC48bCmIi`cs@=MXWH+L+s zu(l>$X)=m(J^a73_TjY7*3oy$vlzLVXA-7pheajK7amv{I22_nBzC<`ozq?3j4I0J zGLhcJ5)l2G6FKbJdV1H_*!M(o{l{hj=aMih*_x&dmNVt)VVbyxvePUL7>c{Fj^o@L zw#3UrGWgd+OhgO)zq<~7uNmc3unK1nMO5Jx9k#Q0a@pkS3o*tud^S0)KfBjji(>cQvlRjw{&DK<=(v+q-VzG`K{MnfNlrgy6XCTxP!@2l(TD& zpc!=?r`5;^O`x#ou%&uS=33FhYHCrx8LPf&Uz^Qz6qmI%XT2Y3f>(HCp`5ibtko$> z)b^}g)?H3EiFAM?ckPD-^_EB6C>Q4$6dZf0{)ZK^TZ2hA=@u8=+c8s5`?LS;wcjji z69QUBt~jWtVjlNCD*{hNs*g^MVW!=56T6v!b?kGXSrlU z>lCIIm@wO`qMv|p8X=!*3>ucOf~qb5d6igGBaNM`86v0Py9DW6JO?~#AnQ2R6|W^N zl?`tMh*;Z}WRxW_FR}#2!tzf{r`I?j)fe%b*z`1mD|*IGj< zbyu5Q=U^F0&o-tz66`W?pVazI+?lu?%Qn>e{)>l4lbFCn^hapn3TS~65c8S-vavl{ z&$nrieH^($Xk2c-t&~e+JyCQC(QTL@>T)}rvY%FjTp817m=E3;WX+Nx82vQFhLE>WcpGy*HQB$Sw@wE&{tU4%9SR&p z1jbb~`_PjYfz?^|dDrkJ809t4A(9Jl=V+UcIoJLN*bI*oD=1bjUK{W(WjoWelT0Rf zWovEhpPI{t*SN95M=QLuDERG!Tn1VRB2-D1_HkNsw`$YQYa>hXiv-64>t9$TjO+uK zcKBRTM{r<*C3|b$lxFAKa`KJG-8HGZtbTVRR02Y9W8MZEKkcs0xj0dOlbTx5(s)v(AfHbEazpT)o$qQ19SU8Zrt?| z*=gpUuJiOFgwf+I43Fw=dNNuEBg&B=sQCa15Vugi$0QkkKyL4!T_yL0p{DHvjU!Ih zSWqYBqt5(uNdtSd4Otir@bJKm3rwDN!1?Y@qBN`hF3V>hrlW;a=dY=zfKpd?@5`!J;DNSiGPZ)J-r{;Mvl2(`D+Vjfxk5$oskSG85 zn9oSM&2>~z(hANdI~6~YH{lhh3cB}~qeBM@_b39EN7h2nq>Af!@`1Z8+=~IZdRmML zFYGrLgsfp4e7_={h=c-AGukkMA+V7$0GOx8phZe4Dlr{9Ym0pqt)L*FZ#|ubb~s`e zCP~Kt6fa=&t6~5(hD-+W9}m+g>c$!6N3!Mi#ERmc)))uMyd7CJ@pby;=8r?U!^MfN_;L2TK1NuiUw z9L3qXTa&oE9VHRl5AXy7yO`)!B#BsS`LWRHn#*M zs*0EG7^{-Y7?mq38JThUEuPvSvY^5_f5T*AGVw6B%BqA{l6I?A3CoH5~T<&!Y zP>*^B!e2(iHMy7ufjf$&yROVHM=YqT%+@ddwHudPq4IdkutZs4?MA$kW|8pwT~FEF+DEXtPxBtMx0rV1VpRlTie0 z*Dt-lNGmGTrH;013e-XI4$guYz_TH5V7#E0{!`tT$h%m;jpTqKYW#O2lCvh%nRS}W zc2P@igUV)7_IJQVFfNDq!w_d7YHM6nNJfC;{bWnb`?1|-opgfM4c-Dgx7ouB)oba1 zK!r}c4$GLC#UL0v8sBq#s975w9h)ozsbUmu#B+ZJ;tw76ACmJj#_HTw@oyV;FwM4X z=iGlmZ|Jd6f$1qs7?Ta^Z59B9Y8a5Owu&i_z??yxCC&y*x=LEghryg^uXh(GI0H)+ zkxKpB!<{IRQsqA+$A9?;?mnJ1*7nf7E73SGfRza7pcA3Nm&Ob;I@IzY z{BKm|k560dwXOo8xl3<7vCB=kuD?IeY|*hM-_K&WRb2;IuoXfIEhT2kkB}ryG_6%D zdcr&(v@5_4Y>Qw4SSAHZZLXzThe#24(luAUYOyvfD7OoXY9h8unr5?9Nl2Pmg6Jlw zzoM5a#($%x^g!?M|Go~~>5=?bmdnJZ8aPg=f>`yFR_(J~P`R_iJ_PW_qzFpgZT=b% ztT5|VWAE3-?eeZy6U4ql(IKRFN=g@>Pk4jCnE)X>aQHLuBTv#fXyV+CQlTw<&NcNrI06$9IVH`yR*kx3 zz^H+a%8*V^uf(|(T4?D$vl5-pgcCf4GR>ToJI3y}yk|A@@l1Tj(-P^N-knQV55oGL zSI@FmC_~1jUU*n7flhOZzabLT6hetHpV}ol_k)`ghc0~z){sipG@MUPnmoKr^LKss z&Dmj#J{Q)W%b<&1ZJY!*lwOD2NKPYM{2v?fYKxjzRalxp18N%fdgaOfm@}j9lQot; z<6-AyorC;zBOG2G&>@zd9#p|-?*r_(xcO~UzATCrCmXH~rVh;x3R+kWfd)Rozd`Rk zuFN(GYhlUpbv;1Ddzd58XXfG>J7DT9g{w|mFt*X0IG6n2$I)Ua_~m)V**kABD#xLv zH8h=V{Xojp;iHdn_8pV6K{@O#d)OO|@6VylJ|mF+jBziniMe*VuZb=;dbtCM%0x!lDWQiwQw6YC) zQ-=MK*hM%t+W6Rn3L-IBuk$W_)6+9GpDUpWEj4ofrindRWZjB3`8j1;C}m0g<%(If z5rgIm8e-B~_t2f0PxLW6^gy9UQ`4C6Il4v?^b1y5%R%}_$Qut4E=cX2x z<8uYr6o+V1*h>aTy& zhDIi_;f}kc2eXU^iuG^lUP?PvYmH?rI}gnZa*0{FOvjXpN|HV_k(O?*$DK2G5Ni3jSxrNgP;HVO6)n-iHVW}1yZ z;iL4&%;_c&hPaD~x~pnDq-#gVa(RLV@}-$iUmg##Vmk9o208;}9Z-Q+Y=V^cNNlU3Q>Pj>!IpF=fbwst&b+L}PXN*shITb53lVbm^7r(N(r!v1 z(0$De2Vw*nFkA4vPMTl3p5U+@7bK!C_5IMwzu>z$v%?@ZRQW%9j<%dlFEM;V)?9XP z@O(mGKSHg0j?>NkOTIb*cNle zkr;cqXn7U04Ds^_>-|;E8eudo)uEd4Lm%s^ShEq

|ee;J-y8?iE$sMg8}6+rqDp)3G$ln-Eq8`UEk)|;bT;f?i!2q+EDEEi zR2MGMs0`v(m;xKV$|8nn+S!7tX0W-Yj6bx6(y>`^(O&LJdXiJEK%Kfr_Ag|ctr8(Ps;sEE#w z2Wj^AMRzmaiFH*!Z9#qIKYhi!dW5cH?J5k4Vk(J#yY0fnB4_u}D;nytb#)Zb&5@^| zpv!3TGAXc*aL=vN6@eiWk`;-p%p@xDW%L-~;TM$wj9N5l=#N!Wg`LA;v;MHJB5xmi z0XKKJ%^5C4JEIjs5duWJhZNtHZXrIWA}BpeH4MIyEc`bcAthaAz@84)G4>~q32#<8 z8Qb9vEF5;IsGQqZifp?K#OxL27M0VN!VS|}pcE2&&mtZ6+C`#xcJYc@3WnLO`ARAZ zpI&mW@CMMAIo+(_p37!TM~EgRGBo71^A78Wtq1RZV>!d>(}ye}sK7%MliW9oA9X(K z3?QpwHt%-fekQvtW_;9mJ}gD0jF;?i6b7A(22hrh`$V=X?I63~#{(1JZopDjd$MX& zT&vW-?UwbYHjBL1J`nAAPpaKzW0eh@f`nRSEi>4^$TTvPIwQIav{l2pJC9B9#H@g2{fn*}kjuay>#7F1U(D+)^%P>Ym3bY`!!bJ8f6sqI zGri{Di3pGQY%7?QtsUP?G;yF;wy-(@I=Lr)Y^>t_OEXIT(7A!&5=(aNQLj@qN=alrC~FVqA^lqkyy=*!UEa`;9>tT=e4>rA2YNj?aW_a*$ zCW+&BOy;3n4`P=<5@t0f#eDUjLvwT`f*J5G#{t}*&e~M1oFA(eO*-6^!NFE4dH_i*4;RlcT`IuB{3s0mFYf5Nnhi% zoJr<3oIuUtozd%KPD7chi7zO#Tp{ajlpP_lL6#mZWA3wWW_GTnIVUD0X&NH*^K#fh zxf@8n`~Fk}ItMR0f~l@la+f}V?$)MR*X$Ms_|CznfwTlS13UdohpW~8I9J9&=fguV zr?6gr9%DBHq!pv-D)~PKQYJiC^RNzqHj3_yt-yqK<|{51n_BC>TJKNrGVgx6uXkNM zwYHP;n4Sb3iBpD*G#472IZ)V8<@n1T-N;X%_n#bXc*r|Ok{Nzw#`mh=MO* z6n`!@cPI=^1>g*yYZK}|=5SK)zyy*(K=Ehq2y11# zc}f^nyCAvw`Cq58_T2A?Q@V7Do}YtpvX1L4UDHm9Po1>3iO*3VB(R$K6o*e)g6uB= zNh0fxc;yAB1%+0KU`>@aECKsJ6$V2#au@ zT|F0XZ3Dtw;EKD<4}HYFBF0x?e*GwvlCJX+X1gAJAoRod*?MuVZI*rNdnEn%^S_3@ z;G4srhTIE}z!^Rfd=p}EwXY7CCl1KdwzzbAp(*!3;nR(7mJy88V5*{mI(~`-hDfNC z1wZg5xMF`U++gGayMqt_-e+?C76vhUXIKmfVHN0kQ_6Go# z9LtakT2bncccF=8Jya;>Si5*)AH)@dRnXANEl7RR`PDx)xLuNjTu<-l#?B%aXeqt=tr%u5W;3C$q~cyx!}C3a`24`C~0L!4Tg`Wf8kkE z4yb2<3Z%P0yJ9g~gE4`;@r@~0ZHqv(HPVh@^PE9BnC)^bq7je^rn)&orEqao)xV0C zb%Cn=RCx4`h2PNFRq|ApdlC7ErKJDnio{Y^KbYX3+UCbXPjG5;+DHFwvVUG|vuyEQ z4_GAd-PE5~Qoq*;e413;xj-JRIwzU=$wD0CM;IbZh_Hno9P}%tsdGG0;7ST!SE-rG zJD8Lmka9IIE*KY|()~Sg6IYGy&cdUYkJfQ`c)c)lb73P^u826`mU9wB-RMek5-w)U zf;{t>J-#IPn7dYBTi+8N30J1U3CE^dhyzu_uLi08>m?egGQf74`IXMNVq{ixJbM*- zTmEqN%WGVH_Sz|#BDa5O^@WXXR%=lk>T+bVf~r? zjl`-88L{8EUf*Bd&Abh4ok`e}x*o8ZZ{>Eh4LF5O=3t!u2?LPJ8nnlscz$XRj(z`y-;@~T{O;iK6zZ4AVxHAUj?WO?-v7%GBO0shzTL^~$ zqzLP6LYjbRMj`XBSZXZ6qlN~&3iRmV-hewPG@~J~zJHGr&kjuZ(b?=a<1!J1+etR3 z?Ec~PmdIAm75NDfw@0=5m1b|56bgaw-6D1ciwhM5%dx?|s0%b?mzfESIeD$%K3Vs4 z671e8yvDOzPT0(kZ>Q}xOBuL!@U53^<56m6SVOab@^NQ~4BUBmP&eo}Vj@L#WESHl zNI?7G{<%G|LlEj}XAD9AQW}odxiS}T2akp}uGAaH zNB8cTU^m(RlX#(klea}BuZCwd_ip1JyXPT2?5IEChEw0S5k~)vu}GIozMpb|=GV z@!{D+{(OP5ae|zz$ZN0H@l=^^ooLS&mRvp!1t61q1XZTI2l3wJ{sQ(?XxmqKeq*+g96|;pjm!C)CVIrsA-aYFD)3(| zKh+8Lfa5>K|H#9yUHw5z_FHE5IUbXFJ$W7y4tlK5!XMMtU0@(sY2d^wPK zswjVj+0lfF6WL05phRfHx$N`*lc2 z`?UCA<$TWa#9pISAv_7UxN_hoN}LMtCn5v|=7ot3`Mz$o$FL0UMlm)!)%Dp)PVkZX z-lkeT=xc2Es!nSyXAmMaV{Hbn#c7NPct(7jmIkBUOuVR#pZ9evUinMZA4{`O7IIVB zj9w0p=MK_}yJt9!*6~>7^GLW`o*nPP88+-UBU)TT6-41YT*a{-6BF#s5lkfML^X(dfpX142GV#% za`F(9DNngPA%Ue5(~BwFV?mkU#9^nN*+_bTDSEi6UJi;WCzB{n1!*3(k%49 zD%@o5B2^sa#tZiiN?7$5xHM0Ow&iF78U38NO`$*1k(+feUilcuV>8X8 zje+H*c2y56Hn{FCs60|N5s4~EB2~r$p1JXbscC6@g0+?6*Jxxp(TcTk2C-sfwv!4N}#F)t;@BL>EpWUBhrETWTna{q$Z$Q6(G?~)CyByl0^V? zRSuQYOyAx?7B#GsI~Nfh?q#QSTpq67=F`w$1L!p3^P!Fw0!;NaYFS=z_bTL0YD@SPo*w}Rn45OIcBeO1K01#SG=fOdR$fbY+Pqp0#U@dup?iY~A8hwi;kM!f}!E;Zy zCU(pG^-!~zT^E+-T_?QN=W$+lV6YqSLteYF@ufr59JO?bZzo9#_s+TNq32}te6nS- zn<;Vy1vF;X(EGw>lv;5j&E914wQ?Zba*|swJU@5uC#7t6A)4QA$LhfDr|*Z-FNSR@ z?n6q<#b74M+r)}@QEH+vWUTE=!!10bkko?DCE7Yd>c7-oJ9$)vO9#OHOz+V!Bx;7Q z3x5wRu|NpA%|qxcw67d1-B9m37P@;Z@WO_nyAx&pBATx`FsqN-iBrQx$Kv~*ETK&e z)9u7ZsJ0y)+Q^_4R!6!16#^{KChzmB!;2paor>8;lwQZmTdB_>EwOAfU<%t zs=%R1qt?)<85c>s%EHJ`d#472jtAH>e2whLI6F}lAzOagF?)%~W6DZGq(^6l91pkl zvDY6!b_*C{MI}SFGZOUlUr>q65ZxxCLV1Y(fcZj6&CKoVFC zJKnNRH*(OPr7DO(HWrpBhs2in0au%OPvN6qH18-8=>djdTEB}Y|ChQ~-@l<=A6xK7QFD;la@T5W2;Ax%i=rR^?u zoY0#+q_*`q_ypGj3KZ+Z0M6})23;EhINcs!bO<`#?``Q*32{WWJd7@<37E}czy5LZ zvBwV+U1Qb{^}tySBFIQ&l_2H)xJqUA3F17~_unTcgBc2X1Jm(bwj_#8oPsh11Et)i z6G^@K5SYOiSgJ<1ioX{nk|Dt6Ha!lX_A+xjMnAlaPH@&Xc*) z`8}xFAD%_~BIW^x+4cB#yLva8Wm7e|Q$JYH&_*#HDp4NBC))Cb2(8z^yV^kW;_ZVV^Ay zrs3hlXK+D|nA*dMYCxyog-p` z#kPxlFh_$9lV1|rafDNjlReMwQ8oq-qy?ITj}8K(LL`xy8NB%XI#_Z-7)k~bJM_u0Z<_;VGcP^grkpA;;Dv;t{3vaQ*vgD<*3bOo1UD); zECgPP7iGz4Je)ju(=6UBbj@>~K;rBdrldU^*$wf&c}ROflq(BeLqWhR2s`JUiZM}6 zWZCqIqycFx=vm2HdRY=c7;BOVD=-WP-um7Zqv;r=<+M>jxTL}-q{GYxtv3M2_gawF z&oQQovB`SaB+@F$p9oX;ASHY`h+d%vn5iy8Lu@yq%1cgdy?7Ewl6R76Zco2xUovh{ zl+9k%atPI0TUY1lk&U!1oC$b13YevNO4e-v>=EP}gViObx~$M^X)L9fWf3-53KE}# zmP%?^nAl@o$R_U?XsQ}eU2W7}m_I`|f`S=Rw1ix2)VK`!@tONxqE zTtZM3N)UHE%W&mg2p9$14CAc?DwdKFaNN-%b7buVSUA<@C|(0nU&s?njKm9ru6LWG zbcJ&(#c|*b%A3sF(`iV&-@o2GZl1)@TD}SUj>AUvgBJO%aH}ad)aaNLwNR%%3G&+(Ni(u(k8=+gNKf(N2o2!a~;i1uiUL+NN zwzJ-67vr{S&ke$iJz%~#Bg&(b5@B51YD(L}3zuC;fBAv-ok>Qm%na0hhs&M}53)MOw3DR;}NwMU=N`KGp`(K2; z_0UvNJCmsKgg6vui1Yn<2t()k54%JcAoO7~@(pQX?@I@ZgiGszYr>6aHg`Igg)Oj4o$7+zP@i>ND z5mCvJSY;F%R}zt(R&6B|Z9_38OikM+T2QNty{=Z6cdqhw9X~k@6Ytq zZ{}y1zs5X|_v`che4pRUGvE1r-tX5wB{~PDi$;_yi_zubb$Yk6IcL>K3H*K!>~)5X zBcpfGMO5RhBhifnwQAAQ4;fjxo9nRVxC({yjdfW!K08F^oJ{xd(j1t;q3d}pO32Qi zJ8|(xQv8h7U#%mCMA`6f*lQLnQ7n_uI{x0#TE%CJNt%gehaGRx&6pZl-kj>u=~ zKvzBU&O3>qrxs<()?;azotx{zZ;5-QcI#UyqNm@Ac+f56UfI8@&*KWDDI^*-NO5xQ zO!?fAUs3tU-0y+8VE(P=9N2cVT)DXLZi4V|fs7@$A(00yBI7M&kH!oM#>o!%Tz{OU z;(ajkjeyF*s&#MNF@u#Q^IOQx)lZEtR*kApd7hqlYn!?@u*PrSa8CJ^(4HI9?k2K5 zz9aSj+Flt9v6i3YLf`z;65q<(%w-*;AjY*O=+|6#FM^mZ| zFf1t)!*S>n^}XgedxN%|be?Y!(acDk*`HIdC(+&t<`;GEPCw4D65T!=7IDn5Iy`v$ z&!U&LDb@!C_+3snBw>;)8y&)_%Ed1+{|nV(w! zr8ia}mWxl1o)W$uKkS&$n5DN~oZkMtX{fkNxLSb}njM#S%;%qt-UVycj;0C=bcs1S zX~dEFYz)Ri((9;oeO={p9!C-yRL8-*d?;W0q=C?5x%> z_V9v_X4r4V)z~?*qsP{Hi_wr~|6B39Q88sy%Au}nrmE2gXd=Rmnqi3ncQu?{)poQ+ zDm8gZg|*14OweBQ2wqt{F+kPtd;h4}zG2S;e%H>78-qR?{m~Vz+eb$dMijTk9?2T2 znbJ*fdiIOn7X#7)w(p^|@w)W!-05Vl;|?8F+O{}?zn16RR|9V9n$Ax$mu;LWos|Zp zF*UV4L-UP;E!P&Y$=*1r_DhKsv6t|7W|9^h{y0DG_=&6UL%qA9Pcm&N# zWiu`#WLgVD9EE}|qbEu%;y+c@fVDuLUn?{U zX=)G0IjOP5!7c7r#~@w3;TY)HjLoXx9^U{1d3>+38fMRCIOdQRTMXR6dG*yG?PuYb zaUHf8q!}%ML1wfotO@D735T7;v4t`FZ~_qI!?^+oX)p%|^@Gm`HhZ+J-Q4P3gm}ze zO>o{?@Y%tJ$Ku?yItl5b0w;Buvn9biRRCp`D;H^t0H?TGv8BLG5&#A=NmyZr$OEj0~Zc+V-fy#dkoNB=GGYa p8z%Dn9j<$k!lKJ`8V}T$If=J2M}ycb=p 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 0000000000000000000000000000000000000000..711c1e32d34e2a3ed70d9db71812bd83dddc11b7 GIT binary patch literal 37771 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 literal 0 HcmV?d00001 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")