From c9fd26f1c7e3532b307fda754b009e37b5ae9719 Mon Sep 17 00:00:00 2001 From: jpmvaz Date: Thu, 29 Oct 2026 20:40:02 +0000 Subject: [PATCH] Unzip budget-app v7 into repo root --- budget-app/.env.example | 33 + budget-app/.gitignore | 6 + budget-app/CHANGELOG.md | 45 + budget-app/README.md | 185 ++ budget-app/VERSION | 1 + budget-app/backend/.dockerignore | 3 + budget-app/backend/Dockerfile | 24 + budget-app/backend/alembic.ini | 35 + budget-app/backend/alembic/env.py | 19 + budget-app/backend/alembic/script.py.mako | 22 + .../backend/alembic/versions/0001_initial.py | 121 ++ .../alembic/versions/0002_departments.py | 74 + .../backend/alembic/versions/0003_mail.py | 69 + .../alembic/versions/0004_selected_months.py | 24 + .../alembic/versions/0005_reopen_archive.py | 42 + .../alembic/versions/0006_upper_management.py | 23 + .../alembic/versions/0007_mfa_avatar_theme.py | 40 + budget-app/backend/app/__init__.py | 0 budget-app/backend/app/audit.py | 25 + budget-app/backend/app/bootstrap.py | 38 + budget-app/backend/app/calc.py | 163 ++ budget-app/backend/app/config.py | 16 + budget-app/backend/app/db.py | 20 + budget-app/backend/app/importer.py | 255 +++ budget-app/backend/app/mailer.py | 334 ++++ budget-app/backend/app/main.py | 25 + budget-app/backend/app/mfa.py | 121 ++ budget-app/backend/app/models.py | 256 +++ budget-app/backend/app/notify.py | 33 + budget-app/backend/app/permissions.py | 95 + budget-app/backend/app/preupgrade.py | 59 + budget-app/backend/app/reports/__init__.py | 0 budget-app/backend/app/reports/charts.py | 110 ++ budget-app/backend/app/reports/excel.py | 196 ++ budget-app/backend/app/reports/i18n.py | 136 ++ budget-app/backend/app/reports/render.py | 52 + .../backend/app/reports/templates/budget.html | 25 + .../app/reports/templates/cost_report.html | 75 + .../backend/app/reports/templates/report.css | 62 + budget-app/backend/app/routers/__init__.py | 0 budget-app/backend/app/routers/activity.py | 76 + budget-app/backend/app/routers/admin.py | 398 ++++ budget-app/backend/app/routers/auth.py | 208 ++ budget-app/backend/app/routers/mail.py | 246 +++ budget-app/backend/app/routers/reports.py | 91 + budget-app/backend/app/routers/years.py | 460 +++++ budget-app/backend/app/security.py | 54 + budget-app/backend/app/seed.py | 56 + budget-app/backend/app/serializers.py | 103 + budget-app/backend/app/summary.py | 66 + budget-app/backend/app/template.py | 316 +++ budget-app/backend/docker-entrypoint.sh | 6 + budget-app/backend/requirements.txt | 13 + budget-app/backend/tests/conftest.py | 40 + .../backend/tests/fixtures/budget_2027.xlsx | Bin 0 -> 141514 bytes budget-app/backend/tests/test_api.py | 113 ++ budget-app/backend/tests/test_calc.py | 30 + budget-app/backend/tests/test_departments.py | 127 ++ budget-app/backend/tests/test_mail.py | 191 ++ budget-app/backend/tests/test_months.py | 71 + budget-app/backend/tests/test_v5.py | 127 ++ budget-app/backend/tests/test_v6.py | 42 + budget-app/backend/tests/test_v7.py | 105 + budget-app/backup/backup.sh | 48 + budget-app/backup/restore.sh | 7 + budget-app/docker-compose.yml | 86 + budget-app/docs/guide.html | 815 ++++++++ budget-app/frontend/.dockerignore | 2 + budget-app/frontend/Caddyfile | 32 + budget-app/frontend/Dockerfile | 11 + budget-app/frontend/index.html | 13 + budget-app/frontend/package-lock.json | 1732 +++++++++++++++++ budget-app/frontend/package.json | 20 + budget-app/frontend/src/App.jsx | 113 ++ budget-app/frontend/src/api.js | 34 + budget-app/frontend/src/auth.js | 9 + .../frontend/src/components/EmailDialog.jsx | 68 + budget-app/frontend/src/components/Layout.jsx | 44 + budget-app/frontend/src/components/ui.jsx | 79 + budget-app/frontend/src/format.js | 25 + budget-app/frontend/src/i18n.js | 865 ++++++++ budget-app/frontend/src/main.jsx | 13 + budget-app/frontend/src/pages/Activity.jsx | 123 ++ budget-app/frontend/src/pages/Departments.jsx | 143 ++ budget-app/frontend/src/pages/Login.jsx | 69 + budget-app/frontend/src/pages/Mail.jsx | 222 +++ budget-app/frontend/src/pages/Profile.jsx | 224 +++ budget-app/frontend/src/pages/Reports.jsx | 113 ++ budget-app/frontend/src/pages/Settings.jsx | 114 ++ budget-app/frontend/src/pages/Users.jsx | 101 + budget-app/frontend/src/pages/YearEditor.jsx | 620 ++++++ budget-app/frontend/src/pages/Years.jsx | 264 +++ budget-app/frontend/src/styles.css | 281 +++ budget-app/frontend/vite.config.js | 7 + 94 files changed, 12098 insertions(+) create mode 100644 budget-app/.env.example create mode 100644 budget-app/.gitignore create mode 100644 budget-app/CHANGELOG.md create mode 100644 budget-app/README.md create mode 100644 budget-app/VERSION create mode 100644 budget-app/backend/.dockerignore create mode 100644 budget-app/backend/Dockerfile create mode 100644 budget-app/backend/alembic.ini create mode 100644 budget-app/backend/alembic/env.py create mode 100644 budget-app/backend/alembic/script.py.mako create mode 100644 budget-app/backend/alembic/versions/0001_initial.py create mode 100644 budget-app/backend/alembic/versions/0002_departments.py create mode 100644 budget-app/backend/alembic/versions/0003_mail.py create mode 100644 budget-app/backend/alembic/versions/0004_selected_months.py create mode 100644 budget-app/backend/alembic/versions/0005_reopen_archive.py create mode 100644 budget-app/backend/alembic/versions/0006_upper_management.py create mode 100644 budget-app/backend/alembic/versions/0007_mfa_avatar_theme.py create mode 100644 budget-app/backend/app/__init__.py create mode 100644 budget-app/backend/app/audit.py create mode 100644 budget-app/backend/app/bootstrap.py create mode 100644 budget-app/backend/app/calc.py create mode 100644 budget-app/backend/app/config.py create mode 100644 budget-app/backend/app/db.py create mode 100644 budget-app/backend/app/importer.py create mode 100644 budget-app/backend/app/mailer.py create mode 100644 budget-app/backend/app/main.py create mode 100644 budget-app/backend/app/mfa.py create mode 100644 budget-app/backend/app/models.py create mode 100644 budget-app/backend/app/notify.py create mode 100644 budget-app/backend/app/permissions.py create mode 100644 budget-app/backend/app/preupgrade.py create mode 100644 budget-app/backend/app/reports/__init__.py create mode 100644 budget-app/backend/app/reports/charts.py create mode 100644 budget-app/backend/app/reports/excel.py create mode 100644 budget-app/backend/app/reports/i18n.py create mode 100644 budget-app/backend/app/reports/render.py create mode 100644 budget-app/backend/app/reports/templates/budget.html create mode 100644 budget-app/backend/app/reports/templates/cost_report.html create mode 100644 budget-app/backend/app/reports/templates/report.css create mode 100644 budget-app/backend/app/routers/__init__.py create mode 100644 budget-app/backend/app/routers/activity.py create mode 100644 budget-app/backend/app/routers/admin.py create mode 100644 budget-app/backend/app/routers/auth.py create mode 100644 budget-app/backend/app/routers/mail.py create mode 100644 budget-app/backend/app/routers/reports.py create mode 100644 budget-app/backend/app/routers/years.py create mode 100644 budget-app/backend/app/security.py create mode 100644 budget-app/backend/app/seed.py create mode 100644 budget-app/backend/app/serializers.py create mode 100644 budget-app/backend/app/summary.py create mode 100644 budget-app/backend/app/template.py create mode 100644 budget-app/backend/docker-entrypoint.sh create mode 100644 budget-app/backend/requirements.txt create mode 100644 budget-app/backend/tests/conftest.py create mode 100644 budget-app/backend/tests/fixtures/budget_2027.xlsx create mode 100644 budget-app/backend/tests/test_api.py create mode 100644 budget-app/backend/tests/test_calc.py create mode 100644 budget-app/backend/tests/test_departments.py create mode 100644 budget-app/backend/tests/test_mail.py create mode 100644 budget-app/backend/tests/test_months.py create mode 100644 budget-app/backend/tests/test_v5.py create mode 100644 budget-app/backend/tests/test_v6.py create mode 100644 budget-app/backend/tests/test_v7.py create mode 100755 budget-app/backup/backup.sh create mode 100755 budget-app/backup/restore.sh create mode 100644 budget-app/docker-compose.yml create mode 100644 budget-app/docs/guide.html create mode 100644 budget-app/frontend/.dockerignore create mode 100644 budget-app/frontend/Caddyfile create mode 100644 budget-app/frontend/Dockerfile create mode 100644 budget-app/frontend/index.html create mode 100644 budget-app/frontend/package-lock.json create mode 100644 budget-app/frontend/package.json create mode 100644 budget-app/frontend/src/App.jsx create mode 100644 budget-app/frontend/src/api.js create mode 100644 budget-app/frontend/src/auth.js create mode 100644 budget-app/frontend/src/components/EmailDialog.jsx create mode 100644 budget-app/frontend/src/components/Layout.jsx create mode 100644 budget-app/frontend/src/components/ui.jsx create mode 100644 budget-app/frontend/src/format.js create mode 100644 budget-app/frontend/src/i18n.js create mode 100644 budget-app/frontend/src/main.jsx create mode 100644 budget-app/frontend/src/pages/Activity.jsx create mode 100644 budget-app/frontend/src/pages/Departments.jsx create mode 100644 budget-app/frontend/src/pages/Login.jsx create mode 100644 budget-app/frontend/src/pages/Mail.jsx create mode 100644 budget-app/frontend/src/pages/Profile.jsx create mode 100644 budget-app/frontend/src/pages/Reports.jsx create mode 100644 budget-app/frontend/src/pages/Settings.jsx create mode 100644 budget-app/frontend/src/pages/Users.jsx create mode 100644 budget-app/frontend/src/pages/YearEditor.jsx create mode 100644 budget-app/frontend/src/pages/Years.jsx create mode 100644 budget-app/frontend/src/styles.css create mode 100644 budget-app/frontend/vite.config.js diff --git a/budget-app/.env.example b/budget-app/.env.example new file mode 100644 index 0000000..170163f --- /dev/null +++ b/budget-app/.env.example @@ -0,0 +1,33 @@ +# Copy this file to .env and change every value marked CHANGE ME. + +# Database password (any long random string). CHANGE ME +POSTGRES_PASSWORD=change-me-db-password + +# Signs login sessions. Generate one with: openssl rand -hex 32 CHANGE ME +SECRET_KEY=change-me-secret + +# First administrator, created on the first start only. Change the password after signing in. +ADMIN_EMAIL=admin@example.com +ADMIN_PASSWORD=change-me-admin +ADMIN_NAME=Administrator + +# Interface language for new users: pt or en +DEFAULT_LANGUAGE=pt + +# Ports on the server. Open http://:8080 in a browser. +HTTP_PORT=8080 +HTTPS_PORT=8443 + +# Leave as :80 for plain HTTP on your network. +# For HTTPS with Caddy's own certificate on your network, use for example: budget.local +# (and set HTTP_PORT=80, HTTPS_PORT=443, COOKIE_SECURE=true) +SITE_ADDRESS=:80 +COOKIE_SECURE=false +SESSION_HOURS=12 + +# Nightly database backups, written to BACKUP_DIR on the server +BACKUP_DIR=./backups +BACKUP_TIME=02:30 +BACKUP_KEEP_DAYS=30 + +TZ=Europe/Lisbon diff --git a/budget-app/.gitignore b/budget-app/.gitignore new file mode 100644 index 0000000..2145e07 --- /dev/null +++ b/budget-app/.gitignore @@ -0,0 +1,6 @@ +.env +backups/ +frontend/node_modules/ +frontend/dist/ +**/__pycache__/ +backend/tests/test.db diff --git a/budget-app/CHANGELOG.md b/budget-app/CHANGELOG.md new file mode 100644 index 0000000..dadef49 --- /dev/null +++ b/budget-app/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +## 7.0.0 +- MFA (two-step sign-in) with an authenticator app (Microsoft Authenticator, Google Authenticator, Authy…): each user turns it on in My profile by scanning a QR code and confirming a code, and gets 8 one-time recovery codes. Sign-in then asks for the 6-digit code after the password. Each code works once; 5 wrong codes lock MFA sign-in for 5 minutes. Turning it off needs the password and a code. Administrators can reset a user's MFA (lost phone) from Users → Edit. Every MFA event is in the activity log. +- Users list: new MFA column (On/Off) and each user's picture. +- Light and dark mode: a switch in the top bar (and on the sign-in page) cycles Automatic → Light → Dark, and My profile → Appearance. The choice is saved per user. +- Profile picture: upload a JPG, PNG, GIF or WebP (up to 5 MB) in My profile; it is cropped to a square, reduced to 256 px and stored in the database, so it is in the backups. Shown in the top bar and the users list; initials when there is none. +- Upgrades keep existing data; migration 0007 only adds columns to users, and a backup is taken first. + +## 6.0.0 +- New role **Upper Management** (Gestão de Topo): sees every department, can switch between them, view budgets and reports, download them (budget file, PDF, Excel) and send them by email. It cannot create, edit, upload, submit, approve, reopen, ask for reopening or delete anything, and never appears as a workflow approver. A department is optional for these users. +- Budgets page: Upper Management users start on "All departments"; the template button is hidden for them. +- Upgrades keep existing data as before; the upgrade takes a backup first (migration 0006 changes no tables). + +## 5.0.0 +- Reopen requests: on an approved budget anyone can ask for it to be reopened, with a mandatory reason. The department's approvers and administrators see the request on the budget, accept it (the budget goes back to draft) or decline it with a reason, and are emailed about it when email is on. Approvers reopening directly must also give a reason. Every request is kept in the budget's history. +- The budget page has Download (the editable budget file, plus the two reports in PDF and Excel) and Send by email, in every status. +- Uploading a file for a department and year that already has a budget asks first ("the file already exists"). On OK, the current version is kept, renamed with the date and time (title and file name) and locked, and the upload becomes the current budget. Approved or in-review budgets can't be replaced until they are reopened. +- Archived versions are listed under Budgets → Show archived versions; they can be viewed, exported and emailed, and only administrators can delete them. + +## 4.0.0 +- Lines can be split over chosen months: tick the months (or a quarter or half-year) and the adjusted amount is divided equally over them; the other months stay at zero. The last chosen month takes the rounding cents. +- The line window shows a live month-by-month preview for the 12-month split and the chosen-months split. +- Excel template: new last column "Split over months" / "Dividir pelos meses" (for example 1-4 or Jan, Fev, Mar, Abr). Older templates still import. +- Rolling a budget over to the next year keeps the chosen months. +- Switching a line from monthly amounts to a split keeps its year total as the base. + +## 3.0.0 +- Send reports by email (PDF and/or Excel) from the Reports page, through the account set up by administrators. +- Email settings for administrators: SMTP server, security, login, sender, signature, and a copy of every message saved in the mailbox's Sent folder over IMAP. Passwords are stored encrypted. +- Mail log for administrators: who sent what to whom, delivery and Sent-copy status, CSV export. +- Activity log for administrators: every action by every user, with filters and CSV export. Report views, downloads, template downloads, sign-outs and failed sign-ins are now recorded too. +- Only administrators can delete a budget, in any status, with a typed confirmation. +- Upgrades: the API now asks the backup container for a fresh backup before every database change, even when the backup container itself is not restarted. + +## 2.0.0 +- Departments: budgets, categories and users belong to a department; the same year can exist once per department. +- Approval workflow per department, set by administrators: ordered steps approved by the department's approvers or by named users. +- Other departments can view and export a budget but not change or approve it. +- Excel template per department (Portuguese or English, blank or pre-filled) that can be filled in and imported. +- Department approvers can manage their department's categories; categories can be copied between departments. +- Upgrades keep existing data: a backup is written before the API starts and migrations run; version 1 data moves into the IT department. + +## 1.0.0 +- First release: cost lines with adjustment rules, xlsx import, cost report and monthly budget online, in PDF and Excel, PT/EN, roles, approval, audit log, nightly backups. diff --git a/budget-app/README.md b/budget-app/README.md new file mode 100644 index 0000000..1736550 --- /dev/null +++ b/budget-app/README.md @@ -0,0 +1,185 @@ +# Budget & Capex + +A self-hosted web app for building yearly department budgets and the "Capex e Custos" cost report. +Users enter cost lines in the browser; the app produces the monthly budget (like the *Budget IT* +sheet) and the cost report (like *Capex_e_Custos_2027*), online and as PDF and Excel. +The interface and the reports are available in Portuguese and English. + +## What's inside + +| Container | Role | +| --- | --- | +| `web` | Caddy: serves the web app and forwards `/api` to the API. Optional HTTPS. | +| `api` | Python (FastAPI): business rules, Excel import, PDF and Excel exports. | +| `db` | PostgreSQL 16. All data lives in the `db-data` Docker volume. | +| `backup` | Dumps the database every night to `./backups` and keeps 30 days. | + +## Install + +You need a server on your network with Docker Engine and the Docker Compose plugin. + +```sh +# 1. copy this folder to the server, then inside it: +cp .env.example .env +# 2. edit .env: set POSTGRES_PASSWORD, SECRET_KEY (openssl rand -hex 32) and ADMIN_PASSWORD +# 3. build and start +docker compose up -d --build +``` + +Open `http://:8080` and sign in with `ADMIN_EMAIL` / `ADMIN_PASSWORD` from `.env`. +Change the password under **My profile** right away. The admin account is created only on the first start; +changing `.env` later does not change it. + +## First steps + +1. **Departments**: the app starts with an **IT** department. Add the others (F&B, Rooms, …); each new + department starts from the default chart of accounts, from no categories, or from a copy of another + department's categories. +2. **Budgets → Import Excel**: choose the department and upload `Budget para 2027.xlsx` (a copy is in + `backend/tests/fixtures/`) or a filled-in template (see below). + The import reads the *Sheet1* and *Budget IT* sheets: 54 lines, the 5.5% rate, every multiplier + (including the ×2.055 and ×3.055 hardware increases) and the remarks. Formulas such as `=5512*12` + are kept as typed calculations. +3. **Users → New user** for each colleague, with their department and role: + * **Viewer**: view and export every budget. + * **Upper Management**: view, download and email the budgets and reports of every department, switching + between departments; changes nothing. A department is optional. + * **Editor**: edit their own department's draft budgets and submit them for review. + * **Approver**: also approve their department's budgets and manage its categories. + * **Administrator**: everything, including departments, workflows, users and sites. + + Everyone can view every department's budgets and reports; only the budget's own department can + change it, and only the people named in its workflow can approve it. + Each user can turn on **MFA** in *My profile* (authenticator app + recovery codes). If someone loses + their phone, an administrator opens *Users → Edit* and presses **Reset MFA**. MFA secrets are encrypted + with `SECRET_KEY`: keep that value when you upgrade, or everyone's MFA has to be reset. +4. **Reports**: pick the year, language and optionally one site, then *View online*, *PDF* or *Excel*. + +## Entering data + +* Each line has a **prior-year actual**, a **base** for the new year and an **adjustment**: + *year rate* (the "Inflation + adjustments" % on the budget page), a custom *%*, a *factor ×*, + or *none*. The adjusted value is rounded to cents per line. +* Amount fields accept calculations, as in Excel: `12*839,67+3292,56` or `=5512*12`. + Both `1 234,56` and `1,234.56` are understood. +* Months are **split over 12** (December absorbs the rounding cents), **split over chosen months** + (tick for example Jan to Apr: the adjusted amount is divided equally over those months and the rest + stay at zero), or typed one by one (**monthly amounts**), as the revenue lines are. +* Lines can belong to one **site** (Sagres, Quinta, Chiado, Oriente) or to the **group**. Filter any page + or report by site. +* **New year** can copy every line from an earlier year: last year's adjusted budget becomes the + new year's actual and base, which you then replace with real figures. + +## Approval workflow + +Each department has its own workflow, set by an administrator under **Departments**: an ordered list of +steps, each approved either by *the department's approvers* or by *selected users* (who can be in any +department, e.g. finance). With no steps set, one approval by the department's approvers is needed. + +`Draft → step 1 → step 2 → … → Approved`. Only drafts can be edited. An editor submits; whoever may +approve the current step approves (moving it to the next step) or returns it to draft; an approver of +the department or an administrator can reopen an approved budget. Every change (who, when, old and new +value) is in **History** on the budget page. + +## Reopening an approved budget + +Anyone can press **Request reopening** on an approved budget and must say why. The department's approvers +and the administrators see the request on the budget (and get an email when email is set up), and +**Accept and reopen** it (it goes back to draft) or **Decline** it, giving a reason. Approvers can also +reopen directly, again with a reason. All requests stay in the budget's **History**. + +## Download, change and upload again + +On a budget page, **Download → Budget file** gives the budget as an editable Excel file (the template +filled with its lines). Change it and upload it with **Upload Excel** on the same page (or Budgets → +Import Excel). Because that budget already exists, the app asks first. On OK, the current version is +kept but renamed with the date and time, for example `Budget 2027 (2026-10-28 23:00)` and +`Orcamento_IT_2027_2026-10-28_2300.xlsx`, and locked so it can't be edited; the uploaded file becomes +the current budget. Only drafts can be replaced; an approved budget must be reopened first. Archived +versions are under **Budgets → Show archived versions**. + +## Excel template + +**Budgets → Excel template** downloads a workbook for a department and year, in Portuguese or English, +blank or pre-filled with the lines of an existing budget. It has an instructions sheet and a *Lines* +sheet with drop-down lists for category, site and adjustment type. Fill it in and upload it with +**Import Excel**; unknown categories are created in the department and anything skipped is listed. + +## Email + +Administrators set up the sending account under **Email → Sending account**: the SMTP server, port and +security (STARTTLS on 587 or SSL/TLS on 465), the login, the sender address and name, and an optional +signature. With **Save a copy in the Sent folder** on, every message is also stored over IMAP in the +mailbox's Sent folder (found automatically, or named by hand), just like a message sent from Outlook. +Gmail already keeps that copy for SMTP, so turn the option off there to avoid duplicates. For Gmail and +Microsoft 365, use an app password or an account with SMTP sign-in allowed. **Test connection** signs in +without sending; **Send test email** sends a short message. + +Then every user gets **Send by email** on the Reports page: choose recipients, subject, message and the +reports to attach (PDF and/or Excel, in the language and site chosen on the page). + +Passwords are stored encrypted with `SECRET_KEY`. If you change `SECRET_KEY`, enter them again. + +## Logs + +- **Activity** (administrators): who did what and when: sign-ins and failed sign-ins, budgets and + lines created, changed, imported, submitted, approved and deleted, reports viewed, downloaded and + emailed, and admin changes. Filter by user, area, action, date or text, and export to CSV. +- **Email → Mail log** (administrators): every message sent: who sent it, to whom, the subject, + message and attachments, whether it was delivered to the server, and whether the copy reached the + Sent folder. Also exportable to CSV. + +Only administrators can delete a budget (in any status), from the budget's page. It asks you to type the +year to confirm; the deletion stays in the activity log. + +## Backups and restore + +Backups are written to `BACKUP_DIR` (default `./backups`) at `BACKUP_TIME` every night and once at start. + +```sh +docker compose exec backup ls /backups +docker compose exec backup restore.sh /backups/budget_20270101_023000.sql.gz +docker compose restart api +``` + +Copy the `backups` folder to another machine or a NAS regularly; a backup on the same disk does not +protect against losing that disk. + +## Updating + +Your data lives in Docker volumes, not in this folder, so replacing the folder does not touch it. + +1. Replace the folder contents with the new version, keeping your `.env` and `backups` folder. +2. Run `docker compose up -d --build`. + +Before the API applies database changes (migrations), it asks the backup container for a fresh dump +(`backups/budget_..._before_upgrade.sql.gz`) and waits for it; if the backup fails, the upgrade does not +run. So there is always a backup from just before an upgrade. +Upgrading from version 1 moves all existing budgets, categories and users into the **IT** department. +Never run `docker compose down -v`: `-v` deletes the data volumes. + +## HTTPS (optional) + +Plain HTTP on port 8080 is the default, which is fine on a trusted network. For HTTPS, set in `.env`: + +``` +SITE_ADDRESS=budget.yourdomain.local +HTTP_PORT=80 +HTTPS_PORT=443 +COOKIE_SECURE=true +``` + +and make that name resolve to the server. Caddy issues a certificate from its own local authority for +internal names. Browsers will warn until you install its root certificate on each PC: +`docker compose cp web:/data/caddy/pki/authorities/local/root.crt caddy-root.crt`. +With a public domain pointing at the server, Caddy gets a Let's Encrypt certificate instead. + +## Development + +```sh +cd backend && pip install -r requirements.txt pytest httpx && python -m pytest tests # 44 tests +uvicorn app.main:app --reload # API on :8000 (SQLite) +cd frontend && npm install && npm run dev # web on :5173 +``` + +API documentation is at `/api/docs` when the app is running. diff --git a/budget-app/VERSION b/budget-app/VERSION new file mode 100644 index 0000000..66ce77b --- /dev/null +++ b/budget-app/VERSION @@ -0,0 +1 @@ +7.0.0 diff --git a/budget-app/backend/.dockerignore b/budget-app/backend/.dockerignore new file mode 100644 index 0000000..5ca1f24 --- /dev/null +++ b/budget-app/backend/.dockerignore @@ -0,0 +1,3 @@ +tests +**/__pycache__ +*.db diff --git a/budget-app/backend/Dockerfile b/budget-app/backend/Dockerfile new file mode 100644 index 0000000..83dfd64 --- /dev/null +++ b/budget-app/backend/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +# WeasyPrint needs Pango; DejaVu fonts give the reports a consistent look. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0 \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /srv +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY alembic.ini . +COPY alembic ./alembic +COPY app ./app +COPY docker-entrypoint.sh . +RUN chmod +x docker-entrypoint.sh && useradd --system --uid 1000 app && chown -R app /srv +USER app + +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')" +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/budget-app/backend/alembic.ini b/budget-app/backend/alembic.ini new file mode 100644 index 0000000..ea5d0b2 --- /dev/null +++ b/budget-app/backend/alembic.ini @@ -0,0 +1,35 @@ +[alembic] +prepend_sys_path = . +script_location = alembic + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s diff --git a/budget-app/backend/alembic/env.py b/budget-app/backend/alembic/env.py new file mode 100644 index 0000000..9a6e830 --- /dev/null +++ b/budget-app/backend/alembic/env.py @@ -0,0 +1,19 @@ +from logging.config import fileConfig + +from alembic import context + +from app import models # noqa: F401 +from app.db import Base, engine + +if context.config.config_file_name: + fileConfig(context.config.config_file_name) + + +def run_migrations_online() -> None: + with engine.connect() as connection: + context.configure(connection=connection, target_metadata=Base.metadata, compare_type=True) + with context.begin_transaction(): + context.run_migrations() + + +run_migrations_online() diff --git a/budget-app/backend/alembic/script.py.mako b/budget-app/backend/alembic/script.py.mako new file mode 100644 index 0000000..36cd8ef --- /dev/null +++ b/budget-app/backend/alembic/script.py.mako @@ -0,0 +1,22 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/budget-app/backend/alembic/versions/0001_initial.py b/budget-app/backend/alembic/versions/0001_initial.py new file mode 100644 index 0000000..e8c44bf --- /dev/null +++ b/budget-app/backend/alembic/versions/0001_initial.py @@ -0,0 +1,121 @@ +"""initial schema + +Revision ID: 0001 +Revises: +Create Date: 2026-10-28 20:51:51.396857 +""" +from alembic import op +import sqlalchemy as sa + + +revision = '0001' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('categories', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name_en', sa.String(length=200), nullable=False), + sa.Column('name_pt', sa.String(length=200), nullable=False), + sa.Column('section', sa.String(length=20), nullable=False), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.Column('show_when_empty', sa.Boolean(), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name_en') + ) + op.create_table('sites', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('password_hash', sa.String(length=255), nullable=False), + sa.Column('role', sa.String(length=20), nullable=False), + sa.Column('language', sa.String(length=5), nullable=False), + sa.Column('active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('audit_log', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('at', sa.DateTime(timezone=True), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=True), + sa.Column('year_id', sa.Integer(), nullable=True), + sa.Column('entity', sa.String(length=40), nullable=False), + sa.Column('entity_id', sa.Integer(), nullable=True), + sa.Column('action', sa.String(length=40), nullable=False), + sa.Column('summary', sa.String(length=500), nullable=False), + sa.Column('changes', sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_audit_log_at'), 'audit_log', ['at'], unique=False) + op.create_index(op.f('ix_audit_log_year_id'), 'audit_log', ['year_id'], unique=False) + op.create_table('budget_years', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('year', sa.Integer(), nullable=False), + sa.Column('title', sa.String(length=200), nullable=False), + sa.Column('default_rate', sa.Numeric(precision=9, scale=6), nullable=False), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('notes', sa.Text(), nullable=False), + sa.Column('submitted_by_id', sa.Integer(), nullable=True), + sa.Column('submitted_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('approved_by_id', sa.Integer(), nullable=True), + sa.Column('approved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['approved_by_id'], ['users.id'], ), + sa.ForeignKeyConstraint(['submitted_by_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('year') + ) + op.create_table('cost_lines', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('year_id', sa.Integer(), nullable=False), + sa.Column('category_id', sa.Integer(), nullable=False), + sa.Column('site_id', sa.Integer(), nullable=True), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.String(length=500), nullable=False), + sa.Column('remarks', sa.Text(), nullable=False), + sa.Column('prior_expr', sa.String(length=500), nullable=False), + sa.Column('prior_actual', sa.Numeric(precision=18, scale=6), nullable=False), + sa.Column('base_expr', sa.String(length=500), nullable=False), + sa.Column('base_amount', sa.Numeric(precision=18, scale=6), nullable=False), + sa.Column('adj_kind', sa.String(length=20), nullable=False), + sa.Column('adj_value', sa.Numeric(precision=12, scale=6), nullable=False), + sa.Column('adj_reason', sa.String(length=255), nullable=False), + sa.Column('monthly_kind', sa.String(length=10), nullable=False), + sa.Column('monthly_values', sa.JSON(), nullable=True), + sa.Column('sort_order', sa.Integer(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['category_id'], ['categories.id'], ), + sa.ForeignKeyConstraint(['site_id'], ['sites.id'], ), + sa.ForeignKeyConstraint(['year_id'], ['budget_years.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_cost_lines_year_id'), 'cost_lines', ['year_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_cost_lines_year_id'), table_name='cost_lines') + op.drop_table('cost_lines') + op.drop_table('budget_years') + op.drop_index(op.f('ix_audit_log_year_id'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_at'), table_name='audit_log') + op.drop_table('audit_log') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_table('sites') + op.drop_table('categories') + # ### end Alembic commands ### diff --git a/budget-app/backend/alembic/versions/0002_departments.py b/budget-app/backend/alembic/versions/0002_departments.py new file mode 100644 index 0000000..c0838a8 --- /dev/null +++ b/budget-app/backend/alembic/versions/0002_departments.py @@ -0,0 +1,74 @@ +"""departments, per-department categories and budgets, approval workflows + +Existing data is kept: everything created with version 1 is moved into a department called "IT". + +Revision ID: 0002 +Revises: 0001 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0002" +down_revision = "0001" +branch_labels = None +depends_on = None + + +def _drop_unique_on(table: str, columns: list[str]) -> None: + insp = sa.inspect(op.get_bind()) + for uc in insp.get_unique_constraints(table): + if uc["column_names"] == columns: + op.drop_constraint(uc["name"], table, type_="unique") + + +def upgrade() -> None: + op.create_table( + "departments", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=120), nullable=False, unique=True), + sa.Column("active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + ) + op.create_table( + "workflow_steps", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("department_id", sa.Integer(), sa.ForeignKey("departments.id", ondelete="CASCADE"), nullable=False), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("approver_mode", sa.String(length=10), nullable=False, server_default="role"), + sa.Column("approver_ids", sa.JSON(), nullable=True), + ) + op.create_index("ix_workflow_steps_department_id", "workflow_steps", ["department_id"]) + + op.add_column("users", sa.Column("department_id", sa.Integer(), nullable=True)) + op.add_column("categories", sa.Column("department_id", sa.Integer(), nullable=True)) + op.add_column("budget_years", sa.Column("department_id", sa.Integer(), nullable=True)) + op.add_column("budget_years", sa.Column("current_step", sa.Integer(), nullable=True)) + + conn = op.get_bind() + has_data = any(conn.execute(sa.text(f"SELECT 1 FROM {t} LIMIT 1")).first() + for t in ("users", "categories", "budget_years")) + if has_data: + conn.execute(sa.text("INSERT INTO departments (name, active, created_at) VALUES ('IT', true, now())")) + dept_id = conn.execute(sa.text("SELECT id FROM departments WHERE name = 'IT'")).scalar_one() + for table in ("users", "categories", "budget_years"): + conn.execute(sa.text(f"UPDATE {table} SET department_id = :d"), {"d": dept_id}) + conn.execute(sa.text("UPDATE budget_years SET current_step = 0 WHERE status = 'in_review'")) + + op.alter_column("categories", "department_id", nullable=False) + op.alter_column("budget_years", "department_id", nullable=False) + op.create_foreign_key("fk_users_department", "users", "departments", ["department_id"], ["id"]) + op.create_foreign_key("fk_categories_department", "categories", "departments", ["department_id"], ["id"], + ondelete="CASCADE") + op.create_foreign_key("fk_budget_years_department", "budget_years", "departments", ["department_id"], ["id"]) + op.create_index("ix_categories_department_id", "categories", ["department_id"]) + op.create_index("ix_budget_years_department_id", "budget_years", ["department_id"]) + + _drop_unique_on("categories", ["name_en"]) + _drop_unique_on("budget_years", ["year"]) + op.create_unique_constraint("uq_categories_department_name", "categories", ["department_id", "name_en"]) + op.create_unique_constraint("uq_budget_years_department_year", "budget_years", ["department_id", "year"]) + + +def downgrade() -> None: + raise NotImplementedError("Restore a backup taken before the upgrade instead.") diff --git a/budget-app/backend/alembic/versions/0003_mail.py b/budget-app/backend/alembic/versions/0003_mail.py new file mode 100644 index 0000000..0cef800 --- /dev/null +++ b/budget-app/backend/alembic/versions/0003_mail.py @@ -0,0 +1,69 @@ +"""mail account settings and the mail log + +Only adds tables; existing data is untouched. + +Revision ID: 0003 +Revises: 0002 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0003" +down_revision = "0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "mail_settings", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("smtp_host", sa.String(length=255), nullable=False, server_default=""), + sa.Column("smtp_port", sa.Integer(), nullable=False, server_default="587"), + sa.Column("smtp_security", sa.String(length=10), nullable=False, server_default="starttls"), + sa.Column("smtp_username", sa.String(length=255), nullable=False, server_default=""), + sa.Column("smtp_password", sa.Text(), nullable=False, server_default=""), + sa.Column("from_address", sa.String(length=255), nullable=False, server_default=""), + sa.Column("from_name", sa.String(length=255), nullable=False, server_default=""), + sa.Column("reply_to", sa.String(length=255), nullable=False, server_default=""), + sa.Column("signature", sa.Text(), nullable=False, server_default=""), + sa.Column("save_to_sent", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("imap_host", sa.String(length=255), nullable=False, server_default=""), + sa.Column("imap_port", sa.Integer(), nullable=False, server_default="993"), + sa.Column("imap_security", sa.String(length=10), nullable=False, server_default="ssl"), + sa.Column("imap_same_login", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("imap_username", sa.String(length=255), nullable=False, server_default=""), + sa.Column("imap_password", sa.Text(), nullable=False, server_default=""), + sa.Column("sent_folder", sa.String(length=255), nullable=False, server_default=""), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("updated_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + ) + op.create_table( + "mail_log", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("at", sa.DateTime(timezone=True), nullable=False), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("kind", sa.String(length=20), nullable=False), + sa.Column("year_id", sa.Integer(), nullable=True), + sa.Column("context", sa.String(length=255), nullable=False), + sa.Column("sender", sa.String(length=255), nullable=False), + sa.Column("recipients", sa.JSON(), nullable=False), + sa.Column("subject", sa.String(length=500), nullable=False), + sa.Column("body", sa.Text(), nullable=False), + sa.Column("attachments", sa.JSON(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("error", sa.String(length=1000), nullable=False), + sa.Column("sent_copy", sa.String(length=20), nullable=False), + sa.Column("sent_copy_detail", sa.String(length=500), nullable=False), + sa.Column("message_id", sa.String(length=255), nullable=False), + ) + op.create_index("ix_mail_log_at", "mail_log", ["at"]) + op.create_index("ix_mail_log_year_id", "mail_log", ["year_id"]) + + +def downgrade() -> None: + op.drop_index("ix_mail_log_year_id", "mail_log") + op.drop_index("ix_mail_log_at", "mail_log") + op.drop_table("mail_log") + op.drop_table("mail_settings") diff --git a/budget-app/backend/alembic/versions/0004_selected_months.py b/budget-app/backend/alembic/versions/0004_selected_months.py new file mode 100644 index 0000000..edf7eca --- /dev/null +++ b/budget-app/backend/alembic/versions/0004_selected_months.py @@ -0,0 +1,24 @@ +"""cost lines can be split over selected months only + +Only adds a nullable column; existing lines keep their 12-month or monthly-amount split. + +Revision ID: 0004 +Revises: 0003 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0004" +down_revision = "0003" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("cost_lines", sa.Column("monthly_months", sa.JSON(), nullable=True)) + + +def downgrade() -> None: + # lines split over selected months fall back to the 12-month split + op.execute("UPDATE cost_lines SET monthly_kind = 'even' WHERE monthly_kind = 'selected'") + op.drop_column("cost_lines", "monthly_months") diff --git a/budget-app/backend/alembic/versions/0005_reopen_archive.py b/budget-app/backend/alembic/versions/0005_reopen_archive.py new file mode 100644 index 0000000..1debd76 --- /dev/null +++ b/budget-app/backend/alembic/versions/0005_reopen_archive.py @@ -0,0 +1,42 @@ +"""reopen requests, archived budget versions, source file name + +Existing budgets stay as they are (not archived). The one-budget-per-department-and-year rule now only +applies to current budgets, so a replaced budget can be kept, archived, next to its replacement. + +Revision ID: 0005 +Revises: 0004 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0005" +down_revision = "0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("budget_years", sa.Column("source_file", sa.String(length=255), nullable=False, server_default="")) + op.add_column("budget_years", sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false())) + op.add_column("budget_years", sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column("budget_years", sa.Column("archived_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True)) + op.drop_constraint("uq_budget_years_department_year", "budget_years", type_="unique") + op.create_index("uq_budget_years_current", "budget_years", ["department_id", "year"], unique=True, + postgresql_where=sa.text("NOT archived"), sqlite_where=sa.text("NOT archived")) + op.create_table( + "reopen_requests", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("year_id", sa.Integer(), sa.ForeignKey("budget_years.id", ondelete="CASCADE"), nullable=False), + sa.Column("requested_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("requested_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("reason", sa.Text(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("decided_by_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("decision_note", sa.Text(), nullable=False), + ) + op.create_index("ix_reopen_requests_year_id", "reopen_requests", ["year_id"]) + + +def downgrade() -> None: + raise NotImplementedError("restore the backup taken before the upgrade instead") diff --git a/budget-app/backend/alembic/versions/0006_upper_management.py b/budget-app/backend/alembic/versions/0006_upper_management.py new file mode 100644 index 0000000..90d2f08 --- /dev/null +++ b/budget-app/backend/alembic/versions/0006_upper_management.py @@ -0,0 +1,23 @@ +"""Upper Management role + +No table changes: users.role already holds the new value "management". The revision exists so that +upgrading to version 6 still takes the before-upgrade backup, like every other version. + +Revision ID: 0006 +Revises: 0005 +""" + +revision = "0006" +down_revision = "0005" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + # users given the new role go back to read-only viewers + from alembic import op + op.execute("UPDATE users SET role = 'viewer' WHERE role = 'management'") diff --git a/budget-app/backend/alembic/versions/0007_mfa_avatar_theme.py b/budget-app/backend/alembic/versions/0007_mfa_avatar_theme.py new file mode 100644 index 0000000..0a5e633 --- /dev/null +++ b/budget-app/backend/alembic/versions/0007_mfa_avatar_theme.py @@ -0,0 +1,40 @@ +"""MFA, profile picture and light/dark theme for users + +Only adds columns to users; existing users keep their data, start without MFA or a picture, and follow +the computer's light/dark setting. + +Revision ID: 0007 +Revises: 0006 +""" +import sqlalchemy as sa +from alembic import op + +revision = "0007" +down_revision = "0006" +branch_labels = None +depends_on = None + +COLUMNS = [ + sa.Column("theme", sa.String(length=10), nullable=False, server_default="system"), + sa.Column("mfa_enabled", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("mfa_secret", sa.String(length=255), nullable=True), + sa.Column("mfa_pending_secret", sa.String(length=255), nullable=True), + sa.Column("mfa_enabled_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("mfa_last_step", sa.BigInteger(), nullable=True), + sa.Column("mfa_recovery", sa.JSON(), nullable=True), + sa.Column("mfa_failed", sa.Integer(), nullable=False, server_default="0"), + sa.Column("mfa_locked_until", sa.DateTime(timezone=True), nullable=True), + sa.Column("avatar", sa.LargeBinary(), nullable=True), + sa.Column("avatar_type", sa.String(length=30), nullable=True), + sa.Column("avatar_updated_at", sa.DateTime(timezone=True), nullable=True), +] + + +def upgrade() -> None: + for column in COLUMNS: + op.add_column("users", column) + + +def downgrade() -> None: + for column in reversed(COLUMNS): + op.drop_column("users", column.name) diff --git a/budget-app/backend/app/__init__.py b/budget-app/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/budget-app/backend/app/audit.py b/budget-app/backend/app/audit.py new file mode 100644 index 0000000..e426d3e --- /dev/null +++ b/budget-app/backend/app/audit.py @@ -0,0 +1,25 @@ +from decimal import Decimal + +from sqlalchemy.orm import Session + +from .models import AuditEntry, User + + +def _plain(value): + if isinstance(value, Decimal): + return str(value.normalize()) if value == value.to_integral() else str(value) + return value + + +def snapshot(obj, fields: list[str]) -> dict: + return {f: _plain(getattr(obj, f)) for f in fields} + + +def diff(before: dict, after: dict) -> dict: + return {k: [before.get(k), after.get(k)] for k in after if before.get(k) != after.get(k)} + + +def record(db: Session, user: User | None, entity: str, entity_id: int | None, action: str, + summary: str = "", changes: dict | None = None, year_id: int | None = None) -> None: + db.add(AuditEntry(user_id=user.id if user else None, entity=entity, entity_id=entity_id, + action=action, summary=summary[:500], changes=changes, year_id=year_id)) diff --git a/budget-app/backend/app/bootstrap.py b/budget-app/backend/app/bootstrap.py new file mode 100644 index 0000000..13ca9bc --- /dev/null +++ b/budget-app/backend/app/bootstrap.py @@ -0,0 +1,38 @@ +"""Run once at container start: creates the first admin and the default categories and sites.""" +from sqlalchemy import func, select + +from .config import settings +from .db import SessionLocal +from .models import Category, Department, Site, User +from .security import hash_password +from .seed import CATEGORIES, SITES + +DEFAULT_DEPARTMENT = "IT" + + +def seed_categories(db, department: Department) -> None: + for i, (en, pt, section) in enumerate(CATEGORIES): + db.add(Category(department_id=department.id, name_en=en, name_pt=pt, section=section, sort_order=(i + 1) * 10)) + + +def bootstrap() -> None: + with SessionLocal() as db: + if not db.scalar(select(func.count(Department.id))): + dept = Department(name=DEFAULT_DEPARTMENT) + db.add(dept) + db.flush() + seed_categories(db, dept) + if not db.scalar(select(func.count(User.id))): + db.add(User(email=settings.admin_email.lower(), name=settings.admin_name, + password_hash=hash_password(settings.admin_password), role="admin", + language=settings.default_language, + department_id=db.scalar(select(Department.id).order_by(Department.id)))) + print(f"Created admin user {settings.admin_email}") + if not db.scalar(select(func.count(Site.id))): + for name in SITES: + db.add(Site(name=name)) + db.commit() + + +if __name__ == "__main__": + bootstrap() diff --git a/budget-app/backend/app/calc.py b/budget-app/backend/app/calc.py new file mode 100644 index 0000000..481dc0a --- /dev/null +++ b/budget-app/backend/app/calc.py @@ -0,0 +1,163 @@ +"""Amount expressions and budget arithmetic. All money math uses Decimal.""" +import ast +import operator +from dataclasses import dataclass, field +from decimal import ROUND_HALF_UP, Decimal, InvalidOperation + +MONTHS = 12 +ZERO = Decimal("0") + +_OPS = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, +} + + +class ExpressionError(ValueError): + pass + + +def normalize_number_text(text: str) -> str: + """Accept '1 234,56', '1.234,56' and '1,234.56' as well as plain numbers.""" + s = text.replace(" ", "").replace(" ", "").replace("€", "") + if "," in s and "." in s: + if s.rfind(",") > s.rfind("."): + s = s.replace(".", "").replace(",", ".") + else: + s = s.replace(",", "") + elif "," in s: + s = s.replace(",", ".") + return s + + +def evaluate(expr: str | None) -> Decimal: + """Evaluate a typed amount such as '=5512*12' or '3292.56+(12*839.67)'.""" + if expr is None: + return ZERO + s = str(expr).strip() + if s.startswith("="): + s = s[1:] + if not s: + return ZERO + s = normalize_number_text(s) + if len(s) > 400: + raise ExpressionError("expression too long") + try: + tree = ast.parse(s, mode="eval") + except SyntaxError as exc: + raise ExpressionError(f"invalid expression: {expr}") from exc + + def walk(node: ast.AST) -> Decimal: + if isinstance(node, ast.Expression): + return walk(node.body) + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)) and not isinstance(node.value, bool): + return Decimal(str(node.value)) + if isinstance(node, ast.BinOp) and type(node.op) in _OPS: + left, right = walk(node.left), walk(node.right) + if isinstance(node.op, ast.Div) and right == 0: + raise ExpressionError("division by zero") + return _OPS[type(node.op)](left, right) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)): + value = walk(node.operand) + return -value if isinstance(node.op, ast.USub) else value + raise ExpressionError(f"invalid expression: {expr}") + + try: + return walk(tree) + except InvalidOperation as exc: + raise ExpressionError(f"invalid expression: {expr}") from exc + + +def round2(value: Decimal) -> Decimal: + return Decimal(value).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + + +def factor_for(adj_kind: str, adj_value: Decimal, year_rate: Decimal) -> Decimal: + adj_value = Decimal(adj_value or 0) + if adj_kind == "year_rate": + return Decimal(1) + Decimal(year_rate or 0) + if adj_kind == "percent": + return Decimal(1) + adj_value / Decimal(100) + if adj_kind == "factor": + return adj_value + return Decimal(1) + + +@dataclass +class LineFigures: + prior: Decimal + base: Decimal + factor: Decimal + adjusted: Decimal + months: list[Decimal] + + @property + def change(self) -> Decimal: + return self.adjusted - self.prior + + @property + def change_pct(self) -> Decimal | None: + return None if self.prior == 0 else self.change / self.prior * 100 + + +def line_figures(line, year_rate: Decimal) -> LineFigures: + # Each line is rounded to cents before it is summed, as the finance reports do. + prior = round2(Decimal(line.prior_actual or 0)) + if line.monthly_kind == "manual": + months = [Decimal(str(v or 0)) for v in (line.monthly_values or [])][:MONTHS] + months += [ZERO] * (MONTHS - len(months)) + total = sum(months, ZERO) + return LineFigures(prior, total, Decimal(1), total, months) + base = round2(Decimal(line.base_amount or 0)) + factor = factor_for(line.adj_kind, line.adj_value, year_rate) + adjusted = round2(Decimal(line.base_amount or 0) * factor) + if line.monthly_kind == "selected": + return LineFigures(prior, base, factor, adjusted, selected_spread(adjusted, line.monthly_months)) + return LineFigures(prior, base, factor, adjusted, even_spread(adjusted)) + + +def even_spread(total: Decimal) -> list[Decimal]: + """Twelve months in cents; December takes the rounding remainder so the months add up exactly.""" + month = round2(total / MONTHS) + return [month] * (MONTHS - 1) + [total - month * (MONTHS - 1)] + + +def clean_months(months) -> list[int]: + """Month numbers 1-12, sorted and without repeats.""" + return sorted({int(m) for m in (months or []) if 1 <= int(m) <= MONTHS}) + + +def selected_spread(total: Decimal, months) -> list[Decimal]: + """Equal amounts in cents over the chosen months only (1 = January); the last chosen month takes the + rounding remainder. No months chosen means all twelve.""" + chosen = clean_months(months) or list(range(1, MONTHS + 1)) + share = round2(total / len(chosen)) + out = [ZERO] * MONTHS + for m in chosen: + out[m - 1] = share + out[chosen[-1] - 1] = total - share * (len(chosen) - 1) + return out + + +@dataclass +class Totals: + prior: Decimal = ZERO + base: Decimal = ZERO + adjusted: Decimal = ZERO + months: list[Decimal] = field(default_factory=lambda: [ZERO] * MONTHS) + + def add(self, f: LineFigures) -> None: + self.prior += f.prior + self.base += f.base + self.adjusted += f.adjusted + self.months = [a + b for a, b in zip(self.months, f.months)] + + @property + def change(self) -> Decimal: + return self.adjusted - self.prior + + @property + def change_pct(self) -> Decimal | None: + return None if self.prior == 0 else self.change / self.prior * 100 diff --git a/budget-app/backend/app/config.py b/budget-app/backend/app/config.py new file mode 100644 index 0000000..f0726b0 --- /dev/null +++ b/budget-app/backend/app/config.py @@ -0,0 +1,16 @@ +import os + + +class Settings: + database_url: str = os.getenv("DATABASE_URL", "sqlite:///./budget.db") + secret_key: str = os.getenv("SECRET_KEY", "change-me") + admin_email: str = os.getenv("ADMIN_EMAIL", "admin@example.com") + admin_password: str = os.getenv("ADMIN_PASSWORD", "admin") + admin_name: str = os.getenv("ADMIN_NAME", "Administrator") + default_language: str = os.getenv("DEFAULT_LANGUAGE", "pt") + cookie_secure: bool = os.getenv("COOKIE_SECURE", "false").lower() == "true" + session_hours: int = int(os.getenv("SESSION_HOURS", "12")) + organization: str = os.getenv("ORGANIZATION_NAME", "") + + +settings = Settings() diff --git a/budget-app/backend/app/db.py b/budget-app/backend/app/db.py new file mode 100644 index 0000000..d0bb617 --- /dev/null +++ b/budget-app/backend/app/db.py @@ -0,0 +1,20 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase, sessionmaker + +from .config import settings + +_connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +engine = create_engine(settings.database_url, connect_args=_connect_args, pool_pre_ping=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False) + + +class Base(DeclarativeBase): + pass + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/budget-app/backend/app/importer.py b/budget-app/backend/app/importer.py new file mode 100644 index 0000000..7a7df30 --- /dev/null +++ b/budget-app/backend/app/importer.py @@ -0,0 +1,255 @@ +"""Imports a workbook laid out like 'Budget para 2027.xlsx'. + +Sheet1 holds one row per cost line (prior actual, base, adjusted, remarks); the 'Budget IT' +sheet says which category each row belongs to (column P = '=Sheet1!D') and holds the +revenue lines with typed monthly values. +""" +import os +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from decimal import Decimal +from io import BytesIO +from zoneinfo import ZoneInfo + +import openpyxl +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from .audit import record +from .calc import ExpressionError, evaluate +from .models import BudgetYear, Category, CostLine, Site, User + +REF_RE = re.compile(r"^=\s*'?Sheet1'?!\$?D\$?(\d+)\s*$", re.I) +FACTOR_RE = re.compile(r"^=\s*\$?C\$?\d+\s*\*\s*([\d.]+)\s*$", re.I) +ARITH_RE = re.compile(r"^=?[\d\s.+\-*/()]+$") +HEADER_RE = re.compile(r"^(\d{4})\s+(ATUAL|ACTUAL|SEM AJUSTE|COM AJUSTE)", re.I) + +# Reasons the budget owner gave for factors that differ from the year's rate. +KNOWN_REASONS = { + "hardware": "Aumento de preço do hardware / Hardware price increase", +} + + +class ImportErrorWithDetail(ValueError): + pass + + +@dataclass +class ImportResult: + year: int + lines: int = 0 + categories_created: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + + +def _sheet(wb, *names): + for ws in wb.worksheets: + if ws.title.strip().lower() in [n.lower() for n in names]: + return ws + return None + + +def _amount(formula_cell, value_cell, warnings: list[str], where: str) -> tuple[str, Decimal]: + """Keep a pure-arithmetic formula as the typed expression; otherwise take Excel's cached value.""" + raw = formula_cell.value + if raw is None or raw == "": + return "0", Decimal("0") + if isinstance(raw, (int, float)): + return str(raw), Decimal(str(raw)) + text = str(raw).strip() + if ARITH_RE.match(text): + try: + return text.lstrip("="), evaluate(text) + except ExpressionError: + pass + cached = value_cell.value + if isinstance(cached, (int, float)): + return str(round(cached, 6)), Decimal(str(cached)) + warnings.append(f"{where}: could not read '{text}', used 0") + return "0", Decimal("0") + + +def _category_index(db: Session, department_id: int) -> dict[str, Category]: + cats = db.scalars(select(Category).where(Category.department_id == department_id)) + index = {} + for c in cats: + index[c.name_en.strip().lower()] = c + if c.name_pt: + index.setdefault(c.name_pt.strip().lower(), c) + return index + + +def current_year(db: Session, department_id: int, year: int) -> BudgetYear | None: + return db.scalar(select(BudgetYear).where(BudgetYear.department_id == department_id, BudgetYear.year == year, + BudgetYear.archived.is_(False))) + + +def archived_name(text: str, when: datetime) -> str: + """'Orcamento_IT_2027.xlsx' -> 'Orcamento_IT_2027_2026-10-28_2250.xlsx'; titles get ' (2026-10-28 22:50)'.""" + if "." in text and text.rsplit(".", 1)[1].lower() in ("xlsx", "xlsm", "xls"): + stem, ext = text.rsplit(".", 1) + return f"{stem}_{when:%Y-%m-%d_%H%M}.{ext}" + return f"{text} ({when:%Y-%m-%d %H:%M})" + + +def archive_year(db: Session, existing: BudgetYear, user: User | None) -> None: + """Keep a replaced budget, renamed with the current date and time and locked against changes.""" + when = datetime.now(ZoneInfo(os.getenv("TZ") or "Europe/Lisbon")) + old_title, old_file = existing.title, existing.source_file + existing.title = archived_name(existing.title or f"Budget {existing.year}", when)[:200] + if existing.source_file: + existing.source_file = archived_name(existing.source_file, when)[:255] + existing.archived, existing.archived_at = True, datetime.now(timezone.utc) + existing.archived_by_id = user.id if user else None + db.flush() + record(db, user, "year", existing.id, "archive", + f"{existing.department.name} {existing.year}: '{old_title}' → '{existing.title}'" + + (f" ({old_file} → {existing.source_file})" if old_file else ""), year_id=existing.id) + + +def prepare_year(db: Session, department_id: int, year: int, rate: Decimal, replace: bool, + user: User | None = None, source_file: str = "") -> BudgetYear: + existing = current_year(db, department_id, year) + if existing and not replace: + raise ImportErrorWithDetail("year_exists") + if existing and existing.status != "draft": + raise ImportErrorWithDetail("year_not_draft") + if existing: + archive_year(db, existing, user) + by = BudgetYear(department_id=department_id, year=year, default_rate=rate, source_file=source_file[:255], + title=existing_title(existing, year), notes=existing.notes if existing else "") + db.add(by) + db.flush() + return by + + +def existing_title(existing: BudgetYear | None, year: int) -> str: + if existing is None: + return f"Budget {year}" + # the replacement keeps the original title, without the archive date + title = existing.title + return title[: title.rfind(" (")] if title.endswith(")") and " (" in title else title + + +def import_workbook(db: Session, data: bytes, user: User | None, department_id: int, year: int | None = None, + replace: bool = False, filename: str = "", check_only: bool = False) -> ImportResult: + """Imports the file into a new budget. With check_only, only reads which year it is for.""" + try: + wb_f = openpyxl.load_workbook(BytesIO(data)) + wb_v = openpyxl.load_workbook(BytesIO(data), data_only=True) + except Exception as exc: # openpyxl raises several unrelated types for a bad file + raise ImportErrorWithDetail("not_an_xlsx") from exc + + from .template import TEMPLATE_SHEETS, import_template + if any(ws.title in TEMPLATE_SHEETS for ws in wb_v.worksheets): + return import_template(db, wb_v, wb_f, user, department_id, year, replace, filename, check_only) + + inputs_f, inputs_v = _sheet(wb_f, "Sheet1", "Input", "Inputs"), _sheet(wb_v, "Sheet1", "Input", "Inputs") + budget_f = _sheet(wb_f, "Budget IT", "Budget") + if inputs_f is None or budget_f is None: + raise ImportErrorWithDetail("missing_sheets") + budget_v = wb_v[budget_f.title] + + # The target year comes from the header row ("2026 ATUAL | 2027 SEM AJUSTE | 2027 COM AJUSTE"). + detected = None + for row in inputs_f.iter_rows(min_row=1, max_row=10): + for cell in row: + m = HEADER_RE.match(str(cell.value or "")) + if m and "AJUSTE" in m.group(2).upper(): + detected = int(m.group(1)) + target_year = year or detected + if not target_year: + raise ImportErrorWithDetail("year_not_found") + + rate = inputs_v["B1"].value if isinstance(inputs_v["B1"].value, (int, float)) else 0 + rate = Decimal(str(rate)) + result = ImportResult(year=target_year) + if check_only: + return result + + by = prepare_year(db, department_id, target_year, rate, replace, user, filename) + + cats = _category_index(db, department_id) + next_order = (db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == department_id)) or 0) + 10 + sites = list(db.scalars(select(Site))) + + def category(name: str) -> Category: + nonlocal next_order + key = name.strip().lower() + if key not in cats: + cat = Category(department_id=department_id, + name_en=name.strip().title() if name.isupper() else name.strip(), + section="revenue" if "revenue" in key else "cost", sort_order=next_order) + next_order += 10 + db.add(cat) + db.flush() + cats[key] = cat + result.categories_created.append(cat.name_en) + return cats[key] + + def site_for(name: str): + for s in sites: + if re.search(rf"\b{re.escape(s.name)}\b", name, re.I): + return s + return None + + # Walk 'Budget IT': headings, then lines that point at Sheet1 or hold typed monthly values. + current = None + order = 0 + for r in range(1, budget_f.max_row + 1): + label = budget_f.cell(r, 1).value + p = budget_f.cell(r, 16).value + months_f = [budget_f.cell(r, c).value for c in range(3, 15)] + if not isinstance(label, str) or not label.strip() or label.strip().startswith("…"): + continue + label = label.strip() + if label.lower().startswith("sub total"): + current = None + continue + if p is None and all(v is None for v in months_f): + if r > 9: + current = category(label) + continue + if current is None: + continue + order += 10 + m = REF_RE.match(str(p or "")) + if m: + sr = int(m.group(1)) + short = inputs_f.cell(sr, 1).value + name = str(short).strip() if short else label + where = f"Sheet1 row {sr}" + prior_expr, prior = _amount(inputs_f.cell(sr, 2), inputs_v.cell(sr, 2), result.warnings, where) + base_expr, base = _amount(inputs_f.cell(sr, 3), inputs_v.cell(sr, 3), result.warnings, where) + d_formula = str(inputs_f.cell(sr, 4).value or "") + fm = FACTOR_RE.match(d_formula) + if fm: + factor = Decimal(fm.group(1)) + else: + d_val = inputs_v.cell(sr, 4).value + factor = (Decimal(str(d_val)) / base) if base and isinstance(d_val, (int, float)) else Decimal(1) + if factor == 1 + rate: + adj_kind, adj_value, reason = "year_rate", Decimal(0), "" + else: + adj_kind, adj_value = "factor", factor + reason = KNOWN_REASONS.get(current.name_en.lower(), "") if factor >= 2 else "" + line = CostLine( + year_id=by.id, category_id=current.id, site_id=(s.id if (s := site_for(label + " " + name)) else None), + name=name, description=label if label != name else "", + remarks=str(inputs_f.cell(sr, 5).value or ""), + prior_expr=prior_expr, prior_actual=prior, base_expr=base_expr, base_amount=base, + adj_kind=adj_kind, adj_value=adj_value, adj_reason=reason, sort_order=order) + else: + values = [] + for c in range(3, 15): + v = budget_v.cell(r, c).value + values.append(float(v) if isinstance(v, (int, float)) else 0.0) + line = CostLine(year_id=by.id, category_id=current.id, name=label, monthly_kind="manual", + monthly_values=values, adj_kind="none", sort_order=order) + db.add(line) + result.lines += 1 + + record(db, user, "year", by.id, "import", f"Imported {result.lines} lines into {target_year}", year_id=by.id) + db.commit() + return result diff --git a/budget-app/backend/app/mailer.py b/budget-app/backend/app/mailer.py new file mode 100644 index 0000000..cf93c5c --- /dev/null +++ b/budget-app/backend/app/mailer.py @@ -0,0 +1,334 @@ +"""Sending reports by email through the configured account, with a copy in its Sent folder. + +SMTP sends the message. When "save to Sent" is on, the same message is then appended over IMAP to the +mailbox's Sent folder (found by its \\Sent flag, or by a usual name), so it shows up there like any +message sent from a mail program. Every attempt is written to the mail log, whatever the outcome. +""" +import base64 +import hashlib +import imaplib +import re +import smtplib +import socket +import ssl +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from email.message import EmailMessage +from email.utils import formataddr, formatdate, make_msgid + +from cryptography.fernet import Fernet, InvalidToken +from sqlalchemy.orm import Session + +from .audit import record +from .config import settings +from .models import MailLog, MailSettings, User + +TIMEOUT = 30 +MAX_RECIPIENTS = 25 +ADDRESS = re.compile(r"^[^@\s<>,;\"']+@[^@\s<>,;\"']+\.[^@\s<>,;\"']+$") +SENT_NAMES = ("sent", "sent items", "sent messages", "sent mail", "enviados", "itens enviados", + "mensagens enviadas", "elementos enviados", "correo enviado") + + +class MailError(Exception): + def __init__(self, code: str, detail: str = ""): + super().__init__(code) + self.code = code + self.detail = detail[:500] + + +@dataclass +class Attachment: + name: str + mime: str + data: bytes + + +# --- stored passwords ------------------------------------------------------------------------- + +def _fernet() -> Fernet: + key = hashlib.sha256(("mail-password:" + settings.secret_key).encode()).digest() + return Fernet(base64.urlsafe_b64encode(key)) + + +def encrypt(password: str) -> str: + return _fernet().encrypt(password.encode()).decode() if password else "" + + +def decrypt(token: str) -> str: + if not token: + return "" + try: + return _fernet().decrypt(token.encode()).decode() + except InvalidToken: + # SECRET_KEY changed since the password was saved + raise MailError("password_unreadable") + + +def get_settings(db: Session) -> MailSettings: + cfg = db.get(MailSettings, 1) + if cfg is None: + cfg = MailSettings(id=1) + db.add(cfg) + db.flush() + return cfg + + +def is_ready(cfg: MailSettings) -> bool: + return bool(cfg.enabled and cfg.smtp_host and cfg.from_address) + + +def clean_addresses(values: list[str] | None) -> list[str]: + out = [] + for raw in values or []: + for part in re.split(r"[,;\s]+", raw or ""): + part = part.strip() + if not part: + continue + if not ADDRESS.match(part): + raise MailError("invalid_address", part) + if part.lower() not in (a.lower() for a in out): + out.append(part) + return out + + +# --- SMTP ------------------------------------------------------------------------------------- + +def _smtp(cfg: MailSettings) -> smtplib.SMTP: + context = ssl.create_default_context() + try: + if cfg.smtp_security == "ssl": + server = smtplib.SMTP_SSL(cfg.smtp_host, cfg.smtp_port, timeout=TIMEOUT, context=context) + else: + server = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=TIMEOUT) + server.ehlo() + if cfg.smtp_security == "starttls": + server.starttls(context=context) + server.ehlo() + if cfg.smtp_username: + server.login(cfg.smtp_username, decrypt(cfg.smtp_password)) + return server + except MailError: + raise + except smtplib.SMTPAuthenticationError as exc: + raise MailError("smtp_auth", _smtp_text(exc)) + except ssl.SSLError as exc: + raise MailError("smtp_tls", str(exc)) + except smtplib.SMTPNotSupportedError as exc: + raise MailError("smtp_tls", str(exc)) + except (socket.gaierror, socket.timeout, ConnectionError, OSError) as exc: + raise MailError("smtp_connect", f"{cfg.smtp_host}:{cfg.smtp_port} — {exc}") + except smtplib.SMTPException as exc: + raise MailError("smtp_error", _smtp_text(exc)) + + +def _smtp_text(exc: Exception) -> str: + if isinstance(exc, smtplib.SMTPResponseException): + msg = exc.smtp_error.decode(errors="replace") if isinstance(exc.smtp_error, bytes) else str(exc.smtp_error) + return f"{exc.smtp_code} {msg}" + return str(exc) + + +def smtp_send(cfg: MailSettings, msg: EmailMessage, recipients: list[str]) -> None: + server = _smtp(cfg) + try: + refused = server.send_message(msg, from_addr=cfg.from_address, to_addrs=recipients) + if refused: + raise MailError("smtp_recipients", ", ".join(refused)) + except smtplib.SMTPRecipientsRefused as exc: + raise MailError("smtp_recipients", ", ".join(exc.recipients)) + except smtplib.SMTPException as exc: + raise MailError("smtp_error", _smtp_text(exc)) + except OSError as exc: + raise MailError("smtp_connect", str(exc)) + finally: + try: + server.quit() + except Exception: + pass + + +# --- IMAP (copy in the Sent folder) ----------------------------------------------------------- + +def _imap(cfg: MailSettings) -> imaplib.IMAP4: + host = cfg.imap_host or cfg.smtp_host + username = cfg.smtp_username if cfg.imap_same_login else cfg.imap_username + password = cfg.smtp_password if cfg.imap_same_login else cfg.imap_password + context = ssl.create_default_context() + try: + if cfg.imap_security == "ssl": + box = imaplib.IMAP4_SSL(host, cfg.imap_port, ssl_context=context, timeout=TIMEOUT) + else: + box = imaplib.IMAP4(host, cfg.imap_port, timeout=TIMEOUT) + if cfg.imap_security == "starttls": + box.starttls(ssl_context=context) + box.login(username, decrypt(password)) + return box + except MailError: + raise + except imaplib.IMAP4.error as exc: + raise MailError("imap_auth", _imap_text(exc)) + except ssl.SSLError as exc: + raise MailError("imap_tls", str(exc)) + except (socket.gaierror, socket.timeout, ConnectionError, OSError) as exc: + raise MailError("imap_connect", f"{host}:{cfg.imap_port} — {exc}") + + +def _imap_text(exc: Exception) -> str: + text = exc.args[0] if exc.args else str(exc) + return text.decode(errors="replace") if isinstance(text, bytes) else str(text) + + +_LIST_LINE = re.compile(r'^\((?P[^)]*)\)\s+(?P"(?:[^"\\]|\\.)*"|NIL)\s+(?P.+)$') + + +def _unquote(text: str) -> str: + text = text.strip() + if len(text) >= 2 and text[0] == text[-1] == '"': + return re.sub(r"\\(.)", r"\1", text[1:-1]) + return text + + +def list_folders(box: imaplib.IMAP4) -> list[tuple[str, str, set[str]]]: + """(name, delimiter, flags) of every folder. Names stay in IMAP's own encoding for APPEND.""" + typ, data = box.list() + if typ != "OK": + raise MailError("imap_error", "LIST failed") + folders = [] + for item in data: + if isinstance(item, tuple): # name sent as a literal: b'(... ) "/" {12}', b'Itens Enviad' + head, literal = item + line = head.decode(errors="replace") + m = _LIST_LINE.match(re.sub(r"\{\d+\}$", '""', line)) + if m: + folders.append((literal.decode(errors="replace"), _unquote(m["delim"]), + set(m["flags"].lower().split()))) + continue + if not item: + continue + m = _LIST_LINE.match(item.decode(errors="replace")) + if m: + delim = "" if m["delim"] == "NIL" else _unquote(m["delim"]) + folders.append((_unquote(m["name"]), delim, set(m["flags"].lower().split()))) + return folders + + +def find_sent_folder(box: imaplib.IMAP4, configured: str) -> str: + folders = list_folders(box) + if configured: + if any(name == configured for name, _, _ in folders): + return configured + raise MailError("sent_folder_not_found", configured) + for name, _, flags in folders: + if "\\sent" in flags: + return name + for name, delim, _ in folders: + leaf = name.split(delim)[-1] if delim else name + if leaf.lower() in SENT_NAMES: + return name + raise MailError("sent_folder_not_found", ", ".join(name for name, _, _ in folders)[:400]) + + +def _quote(name: str) -> str: + return '"' + name.replace("\\", "\\\\").replace('"', '\\"') + '"' + + +def save_copy(cfg: MailSettings, msg: EmailMessage) -> str: + box = _imap(cfg) + try: + folder = find_sent_folder(box, cfg.sent_folder.strip()) + typ, data = box.append(_quote(folder), r"(\Seen)", imaplib.Time2Internaldate(time.time()), msg.as_bytes()) + if typ != "OK": + raise MailError("imap_error", _imap_text(Exception(*(data or [b"APPEND failed"])))) + return folder + except imaplib.IMAP4.error as exc: + raise MailError("imap_error", _imap_text(exc)) + finally: + try: + box.logout() + except Exception: + pass + + +# --- the whole send --------------------------------------------------------------------------- + +def build_message(cfg: MailSettings, to: list[str], cc: list[str], subject: str, body: str, + attachments: list[Attachment]) -> EmailMessage: + msg = EmailMessage() + msg["From"] = formataddr((cfg.from_name or "", cfg.from_address)) + msg["To"] = ", ".join(to) + if cc: + msg["Cc"] = ", ".join(cc) + if cfg.reply_to: + msg["Reply-To"] = cfg.reply_to + msg["Subject"] = " ".join(subject.split()) + msg["Date"] = formatdate(localtime=True) + msg["Message-ID"] = make_msgid(domain=cfg.from_address.split("@")[-1]) + text = body.strip() + if cfg.signature.strip(): + text += "\n\n-- \n" + cfg.signature.strip() + msg.set_content(text + "\n") + for a in attachments: + maintype, subtype = a.mime.split(";")[0].split("/", 1) + msg.add_attachment(a.data, maintype=maintype, subtype=subtype, filename=a.name) + return msg + + +def send(db: Session, user: User, cfg: MailSettings, *, to: list[str], cc: list[str], subject: str, body: str, + attachments: list[Attachment], kind: str = "report", year_id: int | None = None, + context: str = "") -> MailLog: + """Send, save the Sent copy, and log it. Never raises for delivery problems: the log says what happened.""" + log = MailLog(at=datetime.now(timezone.utc), user_id=user.id, kind=kind, year_id=year_id, context=context[:255], + sender=cfg.from_address, recipients={"to": to, "cc": cc}, subject=subject[:500], body=body, + attachments=[{"name": a.name, "size": len(a.data)} for a in attachments], + status="failed", error="", sent_copy="off", sent_copy_detail="", message_id="") + try: + msg = build_message(cfg, to, cc, subject, body, attachments) + log.message_id = msg["Message-ID"] + smtp_send(cfg, msg, to + cc) + log.status = "sent" + except MailError as exc: + log.error = (exc.code + (f": {exc.detail}" if exc.detail else ""))[:1000] + except (ValueError, TypeError) as exc: + log.error = f"invalid_message: {exc}"[:1000] + + if log.status == "sent" and cfg.save_to_sent: + try: + folder = save_copy(cfg, msg) + log.sent_copy, log.sent_copy_detail = "saved", folder[:500] + except MailError as exc: + log.sent_copy = "failed" + log.sent_copy_detail = (exc.code + (f": {exc.detail}" if exc.detail else ""))[:500] + + db.add(log) + db.flush() + where = ", ".join(to + cc) + record(db, user, "mail", log.id, "send" if log.status == "sent" else "send_failed", + f"{subject} → {where}"[:500], year_id=year_id) + db.commit() + return log + + +def check(cfg: MailSettings) -> dict: + """Sign in to SMTP (and IMAP when the Sent copy is on) without sending anything.""" + result = {"smtp": "ok", "imap": "off", "sent_folder": None} + try: + server = _smtp(cfg) + server.quit() + except MailError as exc: + result["smtp"] = exc.code + (f": {exc.detail}" if exc.detail else "") + if cfg.save_to_sent: + try: + box = _imap(cfg) + try: + result["sent_folder"] = find_sent_folder(box, cfg.sent_folder.strip()) + result["imap"] = "ok" + finally: + try: + box.logout() + except Exception: + pass + except MailError as exc: + result["imap"] = exc.code + (f": {exc.detail}" if exc.detail else "") + return result diff --git a/budget-app/backend/app/main.py b/budget-app/backend/app/main.py new file mode 100644 index 0000000..680dc98 --- /dev/null +++ b/budget-app/backend/app/main.py @@ -0,0 +1,25 @@ +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from . import models # noqa: F401 (registers tables) +from .routers import activity, admin, auth, mail, reports, years + +app = FastAPI(title="Budget & Capex", docs_url="/api/docs", openapi_url="/api/openapi.json") + + +@app.middleware("http") +async def csrf_guard(request: Request, call_next): + # Cookies are SameSite=Lax; requiring a custom header on writes also blocks cross-site form posts. + if request.method not in ("GET", "HEAD", "OPTIONS") and request.url.path.startswith("/api/"): + if request.headers.get("x-requested-with") != "budget-app": + return JSONResponse({"detail": "missing_header"}, status_code=403) + return await call_next(request) + + +@app.get("/api/health") +def health(): + return {"ok": True} + + +for r in (auth.router, admin.router, years.router, reports.router, mail.router, activity.router): + app.include_router(r) diff --git a/budget-app/backend/app/mfa.py b/budget-app/backend/app/mfa.py new file mode 100644 index 0000000..2c21147 --- /dev/null +++ b/budget-app/backend/app/mfa.py @@ -0,0 +1,121 @@ +"""MFA with an authenticator app (TOTP, RFC 6238: 6 digits, 30 seconds, SHA-1). + +Works with Microsoft Authenticator, Google Authenticator, Authy, 1Password and similar apps. +""" +import base64 +import hashlib +import hmac +import secrets +import struct +import time +from datetime import datetime, timedelta, timezone +from urllib.parse import quote + +import segno +from cryptography.fernet import Fernet, InvalidToken + +from .config import settings +from .models import User + +ISSUER = "Budget & Capex" +STEP = 30 +WINDOW = 1 # also accept the code just before and just after, for clocks that are slightly off +MAX_FAILED = 5 +LOCK_MINUTES = 5 +RECOVERY_CODES = 8 + + +def _fernet() -> Fernet: + key = hashlib.sha256(("mfa-secret:" + settings.secret_key).encode()).digest() + return Fernet(base64.urlsafe_b64encode(key)) + + +def new_secret() -> str: + return base64.b32encode(secrets.token_bytes(20)).decode().rstrip("=") + + +def seal(secret: str) -> str: + return _fernet().encrypt(secret.encode()).decode() + + +def unseal(token: str | None) -> str | None: + try: + return _fernet().decrypt(token.encode()).decode() if token else None + except InvalidToken: # SECRET_KEY changed: an administrator has to reset this user's MFA + return None + + +def code_at(secret: str, step: int) -> str: + key = base64.b32decode(secret + "=" * (-len(secret) % 8)) + digest = hmac.new(key, struct.pack(">Q", step), hashlib.sha1).digest() + offset = digest[-1] & 0x0F + value = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF + return f"{value % 1_000_000:06d}" + + +def matching_step(secret: str, code: str, last_step: int | None = None, at: float | None = None) -> int | None: + """The time step the code belongs to, or None. A step already used is refused (no replay).""" + code = "".join(ch for ch in code if ch.isdigit()) + if len(code) != 6: + return None + now = int((at or time.time()) // STEP) + for step in range(now - WINDOW, now + WINDOW + 1): + if hmac.compare_digest(code_at(secret, step), code) and (last_step is None or step > last_step): + return step + return None + + +def otpauth_uri(secret: str, email: str) -> str: + label = quote(f"{ISSUER}:{email}") + return f"otpauth://totp/{label}?secret={secret}&issuer={quote(ISSUER)}&algorithm=SHA1&digits=6&period={STEP}" + + +def qr_svg(uri: str) -> str: + return segno.make(uri, error="m").svg_inline(scale=5, dark="#000", light="#fff", border=2) + + +def _hash(code: str) -> str: + return hashlib.sha256(code.replace("-", "").strip().lower().encode()).hexdigest() + + +def new_recovery_codes() -> tuple[list[str], list[str]]: + """Codes to show once, and their hashes to keep.""" + alphabet = "abcdefghjkmnpqrstuvwxyz23456789" + codes = ["".join(secrets.choice(alphabet) for _ in range(4)) + "-" + "".join(secrets.choice(alphabet) for _ in range(4)) + for _ in range(RECOVERY_CODES)] + return codes, [_hash(c) for c in codes] + + +def locked(user: User) -> bool: + until = user.mfa_locked_until + if until and until.tzinfo is None: + until = until.replace(tzinfo=timezone.utc) + return bool(until and until > datetime.now(timezone.utc)) + + +def check(user: User, code: str) -> str | None: + """Checks an authenticator code or a recovery code. Returns 'totp', 'recovery' or None, and updates + the user's counters (a recovery code is used up; too many wrong codes lock MFA for a few minutes).""" + if locked(user): + return None + code = (code or "").strip() + secret = unseal(user.mfa_secret) + kind = None + if secret and (step := matching_step(secret, code, user.mfa_last_step)) is not None: + user.mfa_last_step, kind = step, "totp" + elif any(ch.isalpha() for ch in code) and _hash(code) in (user.mfa_recovery or []): + user.mfa_recovery = [h for h in user.mfa_recovery if h != _hash(code)] + kind = "recovery" + if kind: + user.mfa_failed, user.mfa_locked_until = 0, None + else: + user.mfa_failed = (user.mfa_failed or 0) + 1 + if user.mfa_failed >= MAX_FAILED: + user.mfa_failed, user.mfa_locked_until = 0, datetime.now(timezone.utc) + timedelta(minutes=LOCK_MINUTES) + return kind + + +def disable(user: User) -> None: + user.mfa_enabled, user.mfa_secret, user.mfa_pending_secret = False, None, None + user.mfa_enabled_at, user.mfa_last_step, user.mfa_recovery = None, None, None + user.mfa_failed, user.mfa_locked_until = 0, None diff --git a/budget-app/backend/app/models.py b/budget-app/backend/app/models.py new file mode 100644 index 0000000..02175e9 --- /dev/null +++ b/budget-app/backend/app/models.py @@ -0,0 +1,256 @@ +from datetime import datetime, timezone +from decimal import Decimal + +from sqlalchemy import (JSON, BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, LargeBinary, Numeric, String, Text, + UniqueConstraint, false) +from sqlalchemy import text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .db import Base + +Money = Numeric(18, 6) + + +def now() -> datetime: + return datetime.now(timezone.utc) + + +class Department(Base): + __tablename__ = "departments" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(120), unique=True) + active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + + steps: Mapped[list["WorkflowStep"]] = relationship( + back_populates="department", cascade="all, delete-orphan", order_by="WorkflowStep.position") + + +class WorkflowStep(Base): + """One approval step of a department's workflow. Steps run in `position` order after submission.""" + __tablename__ = "workflow_steps" + + id: Mapped[int] = mapped_column(primary_key=True) + department_id: Mapped[int] = mapped_column(ForeignKey("departments.id", ondelete="CASCADE"), index=True) + position: Mapped[int] = mapped_column(Integer, default=0) + name: Mapped[str] = mapped_column(String(120)) + # role: any approver (or admin-role member) of the department; users: only the listed users + approver_mode: Mapped[str] = mapped_column(String(10), default="role") + approver_ids: Mapped[list | None] = mapped_column(JSON, nullable=True) + + department: Mapped[Department] = relationship(back_populates="steps") + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True) + name: Mapped[str] = mapped_column(String(255)) + password_hash: Mapped[str] = mapped_column(String(255)) + # admin: everything; approver: edit + approve own department; editor: edit own department; viewer: read + role: Mapped[str] = mapped_column(String(20), default="viewer") + department_id: Mapped[int | None] = mapped_column(ForeignKey("departments.id"), nullable=True) + language: Mapped[str] = mapped_column(String(5), default="pt") + active: Mapped[bool] = mapped_column(Boolean, default=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + # light, dark, or system (follow the computer's setting) + theme: Mapped[str] = mapped_column(String(10), default="system", server_default="system") + # MFA with an authenticator app (TOTP). Secrets are stored encrypted; recovery codes as SHA-256 hashes. + mfa_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) + mfa_secret: Mapped[str | None] = mapped_column(String(255), nullable=True) + mfa_pending_secret: Mapped[str | None] = mapped_column(String(255), nullable=True) + mfa_enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + mfa_last_step: Mapped[int | None] = mapped_column(BigInteger, nullable=True) # a code works only once + mfa_recovery: Mapped[list | None] = mapped_column(JSON, nullable=True) + mfa_failed: Mapped[int] = mapped_column(Integer, default=0, server_default="0") + mfa_locked_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + # profile picture, resized to at most 256 px + avatar: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, deferred=True) + avatar_type: Mapped[str | None] = mapped_column(String(30), nullable=True) + avatar_updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + department: Mapped[Department | None] = relationship() + + +class Site(Base): + __tablename__ = "sites" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(120), unique=True) + active: Mapped[bool] = mapped_column(Boolean, default=True) + + +class Category(Base): + __tablename__ = "categories" + __table_args__ = (UniqueConstraint("department_id", "name_en", name="uq_categories_department_name"),) + + id: Mapped[int] = mapped_column(primary_key=True) + department_id: Mapped[int] = mapped_column(ForeignKey("departments.id", ondelete="CASCADE"), index=True) + name_en: Mapped[str] = mapped_column(String(200)) + name_pt: Mapped[str] = mapped_column(String(200), default="") + # revenue lines are shown in the budget but left out of the cost report + section: Mapped[str] = mapped_column(String(20), default="cost") + sort_order: Mapped[int] = mapped_column(Integer, default=0) + show_when_empty: Mapped[bool] = mapped_column(Boolean, default=True) + active: Mapped[bool] = mapped_column(Boolean, default=True) + + +class BudgetYear(Base): + __tablename__ = "budget_years" + # One current budget per department and year; replaced versions are archived next to it. + __table_args__ = (Index("uq_budget_years_current", "department_id", "year", unique=True, + postgresql_where=text("NOT archived"), sqlite_where=text("NOT archived")),) + + id: Mapped[int] = mapped_column(primary_key=True) + department_id: Mapped[int] = mapped_column(ForeignKey("departments.id"), index=True) + year: Mapped[int] = mapped_column(Integer) + title: Mapped[str] = mapped_column(String(200), default="") + default_rate: Mapped[Decimal] = mapped_column(Numeric(9, 6), default=Decimal("0")) + # draft -> in_review (one round per workflow step) -> approved; only drafts can be edited + status: Mapped[str] = mapped_column(String(20), default="draft") + current_step: Mapped[int | None] = mapped_column(Integer, nullable=True) + notes: Mapped[str] = mapped_column(Text, default="") + submitted_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + submitted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + approved_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + # the Excel file the budget was last loaded from, if any + source_file: Mapped[str] = mapped_column(String(255), default="", server_default="") + # an archived budget was replaced by an upload: kept for reference, read-only + archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) + archived_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + archived_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + + department: Mapped[Department] = relationship() + lines: Mapped[list["CostLine"]] = relationship(back_populates="budget_year", cascade="all, delete-orphan") + submitted_by: Mapped[User | None] = relationship(foreign_keys=[submitted_by_id]) + approved_by: Mapped[User | None] = relationship(foreign_keys=[approved_by_id]) + archived_by: Mapped[User | None] = relationship(foreign_keys=[archived_by_id]) + reopen_requests: Mapped[list["ReopenRequest"]] = relationship( + back_populates="budget_year", cascade="all, delete-orphan", order_by="ReopenRequest.id") + + +class CostLine(Base): + __tablename__ = "cost_lines" + + id: Mapped[int] = mapped_column(primary_key=True) + year_id: Mapped[int] = mapped_column(ForeignKey("budget_years.id", ondelete="CASCADE"), index=True) + category_id: Mapped[int] = mapped_column(ForeignKey("categories.id")) + site_id: Mapped[int | None] = mapped_column(ForeignKey("sites.id"), nullable=True) + name: Mapped[str] = mapped_column(String(255)) + description: Mapped[str] = mapped_column(String(500), default="") + remarks: Mapped[str] = mapped_column(Text, default="") + # amounts may be typed as arithmetic, e.g. "3292.56+(12*839.67)"; the evaluated value is stored next to it + prior_expr: Mapped[str] = mapped_column(String(500), default="0") + prior_actual: Mapped[Decimal] = mapped_column(Money, default=Decimal("0")) + base_expr: Mapped[str] = mapped_column(String(500), default="0") + base_amount: Mapped[Decimal] = mapped_column(Money, default=Decimal("0")) + # year_rate: base * (1 + year rate); percent: base * (1 + value/100); factor: base * value; none: base + adj_kind: Mapped[str] = mapped_column(String(20), default="year_rate") + adj_value: Mapped[Decimal] = mapped_column(Numeric(12, 6), default=Decimal("0")) + adj_reason: Mapped[str] = mapped_column(String(255), default="") + # even: adjusted / 12; manual: twelve typed amounts, whose sum is the budget + monthly_kind: Mapped[str] = mapped_column(String(10), default="even") + monthly_values: Mapped[list | None] = mapped_column(JSON, nullable=True) + # monthly_kind "selected": the adjusted amount is split equally over these months (1 = January) + monthly_months: Mapped[list | None] = mapped_column(JSON, nullable=True) + sort_order: Mapped[int] = mapped_column(Integer, default=0) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, onupdate=now) + + budget_year: Mapped[BudgetYear] = relationship(back_populates="lines") + category: Mapped[Category] = relationship() + site: Mapped[Site | None] = relationship() + + +class AuditEntry(Base): + __tablename__ = "audit_log" + + id: Mapped[int] = mapped_column(primary_key=True) + at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + year_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + entity: Mapped[str] = mapped_column(String(40)) + entity_id: Mapped[int | None] = mapped_column(Integer, nullable=True) + action: Mapped[str] = mapped_column(String(40)) + summary: Mapped[str] = mapped_column(String(500), default="") + changes: Mapped[dict | None] = mapped_column(JSON, nullable=True) + + user: Mapped[User | None] = relationship() + + + + +class MailSettings(Base): + """The one mail account the app sends from (row id 1). Passwords are stored encrypted.""" + __tablename__ = "mail_settings" + + id: Mapped[int] = mapped_column(primary_key=True) + enabled: Mapped[bool] = mapped_column(Boolean, default=False) + smtp_host: Mapped[str] = mapped_column(String(255), default="") + smtp_port: Mapped[int] = mapped_column(Integer, default=587) + smtp_security: Mapped[str] = mapped_column(String(10), default="starttls") # starttls | ssl | none + smtp_username: Mapped[str] = mapped_column(String(255), default="") + smtp_password: Mapped[str] = mapped_column(Text, default="") + from_address: Mapped[str] = mapped_column(String(255), default="") + from_name: Mapped[str] = mapped_column(String(255), default="") + reply_to: Mapped[str] = mapped_column(String(255), default="") + signature: Mapped[str] = mapped_column(Text, default="") + # copy of every sent message in the mailbox's Sent folder, over IMAP + save_to_sent: Mapped[bool] = mapped_column(Boolean, default=True) + imap_host: Mapped[str] = mapped_column(String(255), default="") + imap_port: Mapped[int] = mapped_column(Integer, default=993) + imap_security: Mapped[str] = mapped_column(String(10), default="ssl") + imap_same_login: Mapped[bool] = mapped_column(Boolean, default=True) + imap_username: Mapped[str] = mapped_column(String(255), default="") + imap_password: Mapped[str] = mapped_column(Text, default="") + sent_folder: Mapped[str] = mapped_column(String(255), default="") # empty: find it automatically + updated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + updated_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + + updated_by: Mapped[User | None] = relationship() + + +class MailLog(Base): + """One message the app tried to send: who, to whom, what, and how it went.""" + __tablename__ = "mail_log" + + id: Mapped[int] = mapped_column(primary_key=True) + at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now, index=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + kind: Mapped[str] = mapped_column(String(20), default="report") # report | test + year_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) + context: Mapped[str] = mapped_column(String(255), default="") + sender: Mapped[str] = mapped_column(String(255), default="") + recipients: Mapped[dict] = mapped_column(JSON, default=dict) # {"to": [...], "cc": [...]} + subject: Mapped[str] = mapped_column(String(500), default="") + body: Mapped[str] = mapped_column(Text, default="") + attachments: Mapped[list] = mapped_column(JSON, default=list) # [{"name": ..., "size": ...}] + status: Mapped[str] = mapped_column(String(20), default="sent") # sent | failed + error: Mapped[str] = mapped_column(String(1000), default="") + sent_copy: Mapped[str] = mapped_column(String(20), default="off") # saved | failed | off + sent_copy_detail: Mapped[str] = mapped_column(String(500), default="") + message_id: Mapped[str] = mapped_column(String(255), default="") + + user: Mapped[User | None] = relationship() + + +class ReopenRequest(Base): + """A user asks for an approved budget to be reopened, saying why; an approver accepts or declines.""" + __tablename__ = "reopen_requests" + + id: Mapped[int] = mapped_column(primary_key=True) + year_id: Mapped[int] = mapped_column(ForeignKey("budget_years.id", ondelete="CASCADE"), index=True) + requested_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + requested_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now) + reason: Mapped[str] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(20), default="pending") # pending | accepted | declined + decided_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True) + decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + decision_note: Mapped[str] = mapped_column(Text, default="") + + budget_year: Mapped[BudgetYear] = relationship(back_populates="reopen_requests") + requested_by: Mapped[User | None] = relationship(foreign_keys=[requested_by_id]) + decided_by: Mapped[User | None] = relationship(foreign_keys=[decided_by_id]) diff --git a/budget-app/backend/app/notify.py b/budget-app/backend/app/notify.py new file mode 100644 index 0000000..a9652cf --- /dev/null +++ b/budget-app/backend/app/notify.py @@ -0,0 +1,33 @@ +"""Short emails about reopen requests, sent through the configured account when email is on. + +Best effort: a failure is written to the mail log and never blocks the action itself. +""" +from sqlalchemy import select +from sqlalchemy.orm import Session + +from . import mailer +from .models import BudgetYear, MailSettings, User + + +def _ready(db: Session) -> MailSettings | None: + cfg = db.get(MailSettings, 1) + return cfg if cfg and mailer.is_ready(cfg) else None + + +def reopeners(db: Session, budget: BudgetYear) -> list[User]: + """Who can reopen the budget: administrators and the department's approvers.""" + users = db.scalars(select(User).where(User.active)).all() + return [u for u in users if u.role == "admin" or (u.role == "approver" and u.department_id == budget.department_id)] + + +def send(db: Session, actor: User, budget: BudgetYear, to: list[User], subject: str, body: str) -> None: + cfg = _ready(db) + addresses = sorted({u.email for u in to if u.email and u.id != actor.id}) + if not cfg or not addresses: + return + try: + addresses = mailer.clean_addresses(addresses) + except mailer.MailError: + return + mailer.send(db, actor, cfg, to=addresses, cc=[], subject=subject, body=body, attachments=[], kind="notice", + year_id=budget.id, context=f"{budget.department.name} {budget.year}") diff --git a/budget-app/backend/app/permissions.py b/budget-app/backend/app/permissions.py new file mode 100644 index 0000000..fc6136b --- /dev/null +++ b/budget-app/backend/app/permissions.py @@ -0,0 +1,95 @@ +"""Who may do what with a department's budget. + +Everyone signed in can view every budget. Changing and approving is limited to the budget's own +department, following the workflow the administrator set up for it. Upper Management only views, +downloads and emails budgets and reports, in every department. +""" +from fastapi import HTTPException + +from .models import BudgetYear, Department, User, WorkflowStep +from .security import ROLES + + +def rank(user: User) -> int: + return ROLES.index(user.role) + + +def is_admin(user: User) -> bool: + return user.role == "admin" + + +def is_management(user: User) -> bool: + return user.role == "management" + + +def member(user: User, department_id: int) -> bool: + return user.department_id == department_id + + +def can_edit_department(user: User, department_id: int) -> bool: + """Create, import and edit draft budgets of the department.""" + return is_admin(user) or (member(user, department_id) and rank(user) >= ROLES.index("editor")) + + +def steps_of(department: Department) -> list[WorkflowStep]: + return sorted(department.steps, key=lambda s: (s.position, s.id)) + + +def can_approve_step(user: User, step: WorkflowStep | None, department_id: int) -> bool: + if step is None: + # No workflow configured: the department's approvers sign off in one step. + return member(user, department_id) and rank(user) >= ROLES.index("approver") + if step.approver_mode == "users": + return user.id in (step.approver_ids or []) and not is_management(user) + return member(user, department_id) and rank(user) >= ROLES.index("approver") + + +def current_step(budget: BudgetYear) -> WorkflowStep | None: + steps = steps_of(budget.department) + if budget.status != "in_review" or not steps: + return None + index = min(budget.current_step or 0, len(steps) - 1) + return steps[index] + + +def can_reopen(user: User, budget: BudgetYear) -> bool: + return is_admin(user) or (member(user, budget.department_id) and rank(user) >= ROLES.index("approver")) + + +def pending_reopen(budget: BudgetYear): + return next((r for r in budget.reopen_requests if r.status == "pending"), None) + + +def allowed_actions(user: User, budget: BudgetYear) -> dict: + if budget.archived: + # a replaced version: read-only for everyone; administrators may still delete it + return {"edit": False, "submit": False, "approve": False, "return": False, "reopen": False, + "request_reopen": False, "decide_reopen": False, "upload": False, "delete": is_admin(user)} + dept = budget.department_id + editor = can_edit_department(user, dept) + approver = budget.status == "in_review" and can_approve_step(user, current_step(budget), dept) + return { + "edit": budget.status == "draft" and editor, + "submit": budget.status == "draft" and editor, + "approve": approver, + "return": approver or (budget.status == "in_review" and is_admin(user)), + "reopen": budget.status == "approved" and can_reopen(user, budget), + # anyone may ask for an approved budget to be reopened (approvers reopen it straight away) + "request_reopen": budget.status == "approved" and pending_reopen(budget) is None and not is_management(user), + "decide_reopen": budget.status == "approved" and pending_reopen(budget) is not None and can_reopen(user, budget), + "upload": budget.status == "draft" and editor, + # Deleting a budget removes it and its reports for everyone, so only administrators may, in any status. + "delete": is_admin(user), + } + + +def require_action(user: User, budget: BudgetYear, action: str) -> None: + if not allowed_actions(user, budget)[action]: + if action == "edit" and budget.status != "draft": + raise HTTPException(409, "year_not_draft") + raise HTTPException(403, "forbidden") + + +def require_department_editor(user: User, department_id: int) -> None: + if not can_edit_department(user, department_id): + raise HTTPException(403, "forbidden") diff --git a/budget-app/backend/app/preupgrade.py b/budget-app/backend/app/preupgrade.py new file mode 100644 index 0000000..458d70a --- /dev/null +++ b/budget-app/backend/app/preupgrade.py @@ -0,0 +1,59 @@ +"""Run before `alembic upgrade`: when there are database changes to apply, ask the backup container for a +fresh dump and wait for it, so every upgrade can be rolled back to the moment just before it. + +The api stops (and Docker retries it) if the backup fails, unless SKIP_PRE_UPGRADE_BACKUP=true. +""" +import os +import sys +import time +import uuid +from pathlib import Path + +from alembic.config import Config +from alembic.runtime.migration import MigrationContext +from alembic.script import ScriptDirectory + +from .db import engine + +REQUESTS = Path(os.getenv("BACKUP_REQUEST_DIR", "/requests")) +TIMEOUT = int(os.getenv("PRE_UPGRADE_BACKUP_TIMEOUT", "900")) + + +def pending() -> tuple[str | None, str]: + head = ScriptDirectory.from_config(Config("alembic.ini")).get_current_head() + with engine.connect() as conn: + current = MigrationContext.configure(conn).get_current_revision() + return current, head + + +def main() -> int: + current, head = pending() + if current is None or current == head: + return 0 # new database, or nothing to migrate + if os.getenv("SKIP_PRE_UPGRADE_BACKUP", "").lower() == "true": + print(f"pre-upgrade backup skipped ({current} -> {head})", flush=True) + return 0 + if not REQUESTS.is_dir(): + print(f"pre-upgrade backup: {REQUESTS} not mounted, cannot ask the backup container", file=sys.stderr, flush=True) + return 1 + rid = time.strftime("%Y%m%d%H%M%S") + "-" + uuid.uuid4().hex[:6] + (REQUESTS / f"{rid}.request").write_text(f"database upgrade {current} -> {head}\n") + print(f"database upgrade {current} -> {head}: waiting for the backup container to take a backup", flush=True) + done = REQUESTS / f"{rid}.done" + deadline = time.time() + TIMEOUT + while time.time() < deadline: + if done.exists(): + answer = done.read_text().strip() # the backup container clears old answers itself + if answer == "ok": + print("pre-upgrade backup written", flush=True) + return 0 + print("pre-upgrade backup FAILED; not upgrading. See: docker compose logs backup", file=sys.stderr, flush=True) + return 1 + time.sleep(2) + print("pre-upgrade backup did not answer in time; not upgrading. Is the backup container running?", + file=sys.stderr, flush=True) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/budget-app/backend/app/reports/__init__.py b/budget-app/backend/app/reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/budget-app/backend/app/reports/charts.py b/budget-app/backend/app/reports/charts.py new file mode 100644 index 0000000..b417054 --- /dev/null +++ b/budget-app/backend/app/reports/charts.py @@ -0,0 +1,110 @@ +"""Inline SVG charts, so the same markup renders in the browser and in WeasyPrint.""" +import math +from html import escape + +BLUE = "#2f6fd6" +ORANGE = "#e8632b" +INK = "#1d1d1f" +MUTED = "#6b6b70" +GRID = "#e4e4e8" + + +def nice_ticks(lo: float, hi: float, count: int = 8) -> list[float]: + if hi <= lo: + hi = lo + 1 + raw = (hi - lo) / count + mag = 10 ** math.floor(math.log10(raw)) + step = next(m * mag for m in (1, 2, 2.5, 5, 10) if m * mag >= raw) + start = math.floor(lo / step) * step + ticks, v = [], start + while v <= hi + step * 0.001: + ticks.append(round(v, 10)) + v += step + if ticks[-1] < hi: + ticks.append(ticks[-1] + step) + return ticks + + +def _wrap(text: str, width: int = 30, lines: int = 2) -> list[str]: + words, out, cur = text.split(), [], "" + for w in words: + if len(cur) + len(w) + (1 if cur else 0) <= width: + cur = f"{cur} {w}".strip() + else: + out.append(cur) + cur = w + out.append(cur) + out = [o for o in out if o] + if len(out) > lines: + out = out[:lines] + out[-1] = out[-1][: width - 1].rstrip() + "…" + return [o if len(o) <= width else o[: width - 1] + "…" for o in out] or [""] + + +def grouped_bars(rows: list[tuple[str, float, float]], fmt_tick, fmt_value=None, width: int = 900, + height: int = 490) -> str: + """Horizontal bars, two per row (prior in blue, adjusted in orange), sized to fill width x height.""" + if not rows: + return "" + label_w = 250 + top, bottom = 8, 30 + row_h = min(130.0, (height - top - bottom) / len(rows)) + bar_h = min(row_h * 0.34, 36) + plot_w = width - label_w - (80 if fmt_value else 24) + height = round(top + bottom + row_h * len(rows)) + hi = max(max(p, a) for _, p, a in rows) or 1 + ticks = nice_ticks(0, hi, 8) + x = lambda v: label_w + plot_w * v / ticks[-1] + parts = [f''] + for t in ticks: + parts.append(f'') + parts.append(f'{escape(fmt_tick(t))}') + for i, (label, prior, adjusted) in enumerate(rows): + y0 = top + i * row_h + cy = y0 + row_h / 2 + fs = 13 if row_h >= 22 else 11.5 + wrapped = _wrap(label, 34, 2 if row_h >= 30 else 1) + for j, chunk in enumerate(wrapped): + dy = (j - (len(wrapped) - 1) / 2) * (fs + 2) + fs / 3 + parts.append(f'{escape(chunk)}') + for k, (value, colour) in enumerate(((prior, BLUE), (adjusted, ORANGE))): + by = cy - bar_h - 1 + k * (bar_h + 2) + w = max(x(max(value, 0)) - label_w, 0) + parts.append(f'') + if fmt_value and row_h >= 26: + vfs = min(12, bar_h + 1) + parts.append(f'{escape(fmt_value(value))}') + parts.append(f'') + parts.append("") + return "".join(parts) + + +def change_bars(rows: list[tuple[str, float]], fmt_value, width: int = 900, height: int = 520) -> str: + """Ranked % change per category; increases orange, decreases blue.""" + if not rows: + return "" + label_w, top, bottom = 270, 8, 30 + row_h = min(60.0, (height - top - bottom) / len(rows)) + bar = min(row_h * 0.6, 28) + plot_w = width - label_w - 90 + height = round(top + bottom + row_h * len(rows)) + lo = min(0.0, min(v for _, v in rows)) + hi = max(0.0, max(v for _, v in rows)) or 1 + ticks = nice_ticks(lo, hi, 8) + span = ticks[-1] - ticks[0] + x = lambda v: label_w + plot_w * (v - ticks[0]) / span + parts = [f''] + for t in ticks: + parts.append(f'') + parts.append(f'{"+" if t > 0 else ""}{t:g}%') + for i, (label, value) in enumerate(rows): + cy = top + i * row_h + row_h / 2 + x0, x1 = sorted((x(0), x(value))) + colour = ORANGE if value > 0 else BLUE + parts.append(f'{escape(_wrap(label, 38, 1)[0])}') + parts.append(f'') + tx, anchor = (x1 + 6, "start") if value >= 0 else (x0 - 6, "end") + parts.append(f'{escape(fmt_value(value))}') + parts.append(f'') + parts.append("") + return "".join(parts) diff --git a/budget-app/backend/app/reports/excel.py b/budget-app/backend/app/reports/excel.py new file mode 100644 index 0000000..895761e --- /dev/null +++ b/budget-app/backend/app/reports/excel.py @@ -0,0 +1,196 @@ +"""Excel exports with live formulas, so totals keep working when people edit the file.""" +import re +from datetime import datetime +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter + +from ..summary import YearView +from .i18n import T + +MONEY = '#,##0.00;[Red]-#,##0.00' +PCT = '0.00%;[Red]-0.00%' +HEAD_FILL = PatternFill("solid", fgColor="1F3B63") +CAT_FILL = PatternFill("solid", fgColor="EEF2F8") +WHITE_BOLD = Font(bold=True, color="FFFFFF") +BOLD = Font(bold=True) +THIN_TOP = Border(top=Side(style="thin", color="999999")) + + +def _safe_sheet_name(name: str, used: set[str]) -> str: + base = re.sub(r"[\[\]:*?/\\]", "-", name)[:31] or "Sheet" + candidate, n = base, 2 + while candidate.lower() in used: + suffix = f" ({n})" + candidate, n = base[: 31 - len(suffix)] + suffix, n + 1 + used.add(candidate.lower()) + return candidate + + +def _header(ws, row: int, labels: list[str]) -> None: + for i, label in enumerate(labels, start=1): + c = ws.cell(row, i, label) + c.font, c.fill = WHITE_BOLD, HEAD_FILL + c.alignment = Alignment(horizontal="left" if i <= 2 else "right", vertical="center") + + +def _title(ws, title: str, sub: str) -> None: + ws["A1"] = title + ws["A1"].font = Font(bold=True, size=14) + ws["A2"] = sub + ws["A2"].font = Font(color="666666") + + +def _out(wb: Workbook) -> bytes: + buf = BytesIO() + wb.save(buf) + return buf.getvalue() + + +def _meta(view: YearView, t: T, site: str | None) -> str: + bits = [f"{t('status')}: {t(view.year.status)}", f"{t('exported')}: {datetime.now():%Y-%m-%d %H:%M}"] + if site: + bits.insert(0, t("site_filter", site=site)) + return " · ".join(bits) + + +def budget_xlsx(view: YearView, lang: str, site: str | None = None, include_empty: bool = True) -> bytes: + t = T(lang, view.year.year, view.year.department.name) + wb = Workbook() + ws = wb.active + ws.title = _safe_sheet_name(t("budget_title"), set()) + _title(ws, t("budget_title"), t("budget_sub") + " · " + _meta(view, t, site)) + months = t("months") + _header(ws, 4, [t("line"), t("site"), *months, t("total")]) + row = 5 + cost_subtotals = [] + cats = sorted([c for c in view.categories if c.lines or include_empty], + key=lambda c: 0 if c.category.section == "revenue" else 1) + for cv in cats: + ws.cell(row, 1, cv.name(lang)).font = BOLD + for col in range(1, 16): + ws.cell(row, col).fill = CAT_FILL + row += 1 + first = row + for lv in cv.lines: + ws.cell(row, 1, lv.line.name) + ws.cell(row, 2, lv.line.site.name if lv.line.site else t("group")) + for m, value in enumerate(lv.figures.months): + ws.cell(row, 3 + m, float(round(value, 2))).number_format = MONEY + ws.cell(row, 15, f"=SUM(C{row}:N{row})").number_format = MONEY + row += 1 + label = t("revenue_total") if cv.category.section == "revenue" else t("sub_total", name=cv.name(lang)) + ws.cell(row, 1, label).font = BOLD + for col in range(3, 16): + letter = get_column_letter(col) + formula = f"=SUM({letter}{first}:{letter}{row - 1})" if cv.lines else 0 + c = ws.cell(row, col, formula) + c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP + if cv.category.section != "revenue": + cost_subtotals.append(row) + row += 2 + ws.cell(row, 1, t("total_costs")).font = WHITE_BOLD + for col in range(1, 16): + ws.cell(row, col).fill = HEAD_FILL + for col in range(3, 16): + letter = get_column_letter(col) + formula = "=" + "+".join(f"{letter}{r}" for r in cost_subtotals) if cost_subtotals else 0 + c = ws.cell(row, col, formula) + c.number_format, c.font = MONEY, WHITE_BOLD + ws.column_dimensions["A"].width = 52 + ws.column_dimensions["B"].width = 12 + for col in range(3, 16): + ws.column_dimensions[get_column_letter(col)].width = 13 + ws.freeze_panes = "C5" + _inputs_sheet(wb.create_sheet(_safe_sheet_name(t("inputs"), {ws.title.lower()})), view, t, site) + return _out(wb) + + +def _inputs_sheet(ws, view: YearView, t: T, site: str | None) -> None: + """The Sheet1 layout: actual, base, factor, adjusted, change, remarks.""" + _title(ws, t("inputs") + f" {view.year.year}", _meta(view, t, site)) + _header(ws, 4, [t("category"), t("line"), t("site"), t("prior_actual"), t("base"), t("factor"), + t("adjusted"), t("change_eur"), t("change_pct"), t("reason"), t("remarks")]) + row = 5 + for cv in view.with_lines(): + for lv in cv.lines: + f, line = lv.figures, lv.line + ws.cell(row, 1, cv.name(t.lang)) + ws.cell(row, 2, line.name) + ws.cell(row, 3, line.site.name if line.site else t("group")) + ws.cell(row, 4, float(f.prior)).number_format = MONEY + ws.cell(row, 5, float(f.base)).number_format = MONEY + ws.cell(row, 6, float(f.factor)).number_format = "0.0000" + ws.cell(row, 7, f"=ROUND(E{row}*F{row},2)").number_format = MONEY + ws.cell(row, 8, f"=G{row}-D{row}").number_format = MONEY + ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})').number_format = PCT + ws.cell(row, 10, line.adj_reason) + ws.cell(row, 11, line.remarks) + row += 1 + ws.cell(row, 1, t("total")).font = BOLD + for col, letter in ((4, "D"), (5, "E"), (7, "G"), (8, "H")): + c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})") + c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP + c = ws.cell(row, 9, f'=IF(D{row}=0,"",H{row}/D{row})') + c.number_format, c.font = PCT, BOLD + for letter, width in zip("ABCDEFGHIJK", (34, 46, 12, 16, 16, 9, 18, 15, 12, 30, 40)): + ws.column_dimensions[letter].width = width + ws.freeze_panes = "C5" + + +def cost_report_xlsx(view: YearView, lang: str, site: str | None = None) -> bytes: + t = T(lang, view.year.year, view.year.department.name) + wb = Workbook() + ws = wb.active + used: set[str] = set() + ws.title = _safe_sheet_name(t("summary"), used) + _title(ws, t("cost_report_title"), t("summary_sub") + " · " + _meta(view, t, site)) + _header(ws, 4, [t("category"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct")]) + cats = view.with_lines("cost") + row = 5 + for cv in cats: + sheet = wb.create_sheet(_safe_sheet_name(cv.name(lang), used)) + total_row = _category_sheet(sheet, cv, t) + ref = f"'{sheet.title}'" + ws.cell(row, 1, cv.name(lang)) + ws.cell(row, 2, f"={ref}!B{total_row}").number_format = MONEY + ws.cell(row, 3, f"={ref}!C{total_row}").number_format = MONEY + ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY + ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT + row += 1 + ws.cell(row, 1, t("total")).font = BOLD + for col, letter in ((2, "B"), (3, "C")): + c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})") + c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP + ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY + ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT + ws.cell(row, 4).font = ws.cell(row, 5).font = BOLD + for letter, width in zip("ABCDE", (40, 20, 22, 18, 14)): + ws.column_dimensions[letter].width = width + return _out(wb) + + +def _category_sheet(ws, cv, t: T) -> int: + _title(ws, cv.name(t.lang), t("cost_lines", n=len(cv.lines))) + _header(ws, 4, [t("line"), t("prior_actual"), t("adjusted"), t("change_eur"), t("change_pct"), t("site"), t("remarks")]) + row = 5 + for lv in cv.lines: + ws.cell(row, 1, lv.line.name) + ws.cell(row, 2, float(lv.figures.prior)).number_format = MONEY + ws.cell(row, 3, float(lv.figures.adjusted)).number_format = MONEY + ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY + ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT + ws.cell(row, 6, lv.line.site.name if lv.line.site else t("group")) + ws.cell(row, 7, lv.line.remarks) + row += 1 + ws.cell(row, 1, t("total")).font = BOLD + for col, letter in ((2, "B"), (3, "C")): + c = ws.cell(row, col, f"=SUM({letter}5:{letter}{row - 1})") + c.number_format, c.font, c.border = MONEY, BOLD, THIN_TOP + ws.cell(row, 4, f"=C{row}-B{row}").number_format = MONEY + ws.cell(row, 5, f'=IF(B{row}=0,"",D{row}/B{row})').number_format = PCT + for letter, width in zip("ABCDEFG", (46, 18, 20, 16, 12, 12, 40)): + ws.column_dimensions[letter].width = width + return row diff --git a/budget-app/backend/app/reports/i18n.py b/budget-app/backend/app/reports/i18n.py new file mode 100644 index 0000000..aaa7931 --- /dev/null +++ b/budget-app/backend/app/reports/i18n.py @@ -0,0 +1,136 @@ +from decimal import Decimal + +LABELS = { + "en": { + "cost_report_title": "Capex and Costs {year}", + "cost_report_subtitle": "{dept} · cost budget: {prior} actual vs. {year} with adjustment", + "total_across": "Total across {n} categories", + "by_category": "Budget by category", + "by_category_sub": "Total cost per category, {prior} vs. {year}", + "change_by_category": "Change by category", + "change_by_category_sub": "Percentage change from {prior} actual to {year} with adjustment", + "change_legend": "Orange = cost increase · Blue = cost reduction", + "prior_actual": "{prior} actual", + "adjusted": "{year} with adjustment", + "base": "{year} without adjustment", + "change_eur": "Change (€)", + "change_pct": "Change (%)", + "cost_lines": "{n} cost lines", + "cost_line": "1 cost line", + "summary": "Summary", + "summary_sub": "All categories, {prior} actual vs. {year} with adjustment", + "category": "Category", + "line": "Line", + "site": "Site", + "group": "Group", + "total": "Total", + "factor": "Factor", + "reason": "Reason", + "remarks": "Remarks", + "budget_title": "Budget {year} · {dept}", + "budget_sub": "Monthly budget by category", + "revenue_total": "Sub total revenue", + "sub_total": "Sub total {name}", + "total_costs": "Total costs", + "status": "Status", + "exported": "Exported", + "draft": "Draft", + "in_review": "In review", + "approved": "Approved", + "inputs": "Inputs", + "months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + "no_data": "No cost lines yet.", + "site_filter": "Site: {site}", + }, + "pt": { + "cost_report_title": "Capex e Custos para {year}", + "cost_report_subtitle": "{dept} · orçamento de custos: real {prior} vs. {year} com ajuste", + "total_across": "Total de {n} categorias", + "by_category": "Orçamento por categoria", + "by_category_sub": "Custo total por categoria, {prior} vs. {year}", + "change_by_category": "Variação por categoria", + "change_by_category_sub": "Variação percentual do real {prior} para {year} com ajuste", + "change_legend": "Laranja = aumento de custo · Azul = redução de custo", + "prior_actual": "{prior} real", + "adjusted": "{year} com ajuste", + "base": "{year} sem ajuste", + "change_eur": "Variação (€)", + "change_pct": "Variação (%)", + "cost_lines": "{n} linhas de custo", + "cost_line": "1 linha de custo", + "summary": "Resumo", + "summary_sub": "Todas as categorias, real {prior} vs. {year} com ajuste", + "category": "Categoria", + "line": "Linha", + "site": "Unidade", + "group": "Grupo", + "total": "Total", + "factor": "Fator", + "reason": "Motivo", + "remarks": "Observações", + "budget_title": "Orçamento {year} · {dept}", + "budget_sub": "Orçamento mensal por categoria", + "revenue_total": "Subtotal receitas", + "sub_total": "Subtotal {name}", + "total_costs": "Total de custos", + "status": "Estado", + "exported": "Exportado", + "draft": "Rascunho", + "in_review": "Em revisão", + "approved": "Aprovado", + "inputs": "Dados", + "months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"], + "no_data": "Ainda não há linhas de custo.", + "site_filter": "Unidade: {site}", + }, +} + + +class T: + def __init__(self, lang: str, year: int, dept: str = ""): + self.lang = lang if lang in LABELS else "pt" + self.labels = LABELS[self.lang] + self.ctx = {"year": year, "prior": year - 1, "dept": dept} + + def __call__(self, key: str, **kw): + value = self.labels[key] + return value.format(**{**self.ctx, **kw}) if isinstance(value, str) else value + + def _group(self, value: Decimal, decimals: int) -> str: + q = Decimal(1).scaleb(-decimals) if decimals else Decimal(1) + text = f"{abs(Decimal(value)).quantize(q):,.{decimals}f}" + whole, _, frac = text.partition(".") + whole = whole.replace(",", " ") + if not frac: + return whole + return whole + ("," if self.lang == "pt" else ".") + frac + + def eur(self, value, decimals: int = 2, signed: bool = False) -> str: + value = Decimal(value or 0) + sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else ("−" if value < 0 else "") + body = self._group(value, decimals) + return f"{sign}{body} €" if self.lang == "pt" else f"{sign}€{body}" + + def num(self, value, decimals: int = 2) -> str: + value = Decimal(value or 0) + return ("\u2212" if value < 0 else "") + self._group(value, decimals) + + def pct(self, value, signed: bool = True) -> str: + if value is None: + return "—" + value = Decimal(value) + sign = ("+" if value > 0 else "−" if value < 0 else "") if signed else "" + return f"{sign}{self._group(value, 2)}%" + + def short_eur(self, value: float) -> str: + v = abs(value) + sign = "−" if value < 0 else "" + if v >= 1_000_000: + body = f"{v / 1_000_000:g}M" + elif v >= 1000: + body = f"{v / 1000:g}k" + else: + body = f"{v:g}" + if self.lang == "pt": + return f"{sign}{body.replace('.', ',')} €" + return f"{sign}€{body}" diff --git a/budget-app/backend/app/reports/render.py b/budget-app/backend/app/reports/render.py new file mode 100644 index 0000000..07761bc --- /dev/null +++ b/budget-app/backend/app/reports/render.py @@ -0,0 +1,52 @@ +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + +from jinja2 import Environment, FileSystemLoader, select_autoescape + +from ..summary import YearView +from .charts import change_bars, grouped_bars +from .i18n import T + +TEMPLATES = Path(__file__).parent / "templates" +env = Environment(loader=FileSystemLoader(TEMPLATES), autoescape=select_autoescape(["html"])) +CSS = (TEMPLATES / "report.css").read_text() + + +def _exported() -> str: + return datetime.now().strftime("%Y-%m-%d %H:%M") + + +def cost_report_html(view: YearView, lang: str, site: str | None = None, screen: bool = True) -> str: + t = T(lang, view.year.year, view.year.department.name) + cost_cats = view.with_lines("cost") + cats = [] + for cv in cost_cats: + rows = [(lv.line.name, float(lv.figures.prior), float(lv.figures.adjusted)) for lv in cv.lines] + cats.append(SimpleNamespace( + name=cv.name(lang), count=len(cv.lines), totals=cv.totals, + chart=grouped_bars(rows, t.short_eur, lambda v: t.eur(v, 0)))) + overview = grouped_bars([(c.name, float(c.totals.prior), float(c.totals.adjusted)) for c in cats], t.short_eur) + ranked = sorted([c for c in cats if c.totals.change_pct is not None], + key=lambda c: c.totals.change_pct, reverse=True) + change = change_bars([(c.name, float(c.totals.change_pct)) for c in ranked], lambda v: t.pct(v)) + return env.get_template("cost_report.html").render( + t=t, title=t("cost_report_title"), year=view.year, site=site, cats=cats, costs=view.costs, + overview_chart=overview, change_chart=change, exported=_exported(), css=CSS, screen=screen) + + +def budget_html(view: YearView, lang: str, site: str | None = None, include_empty: bool = False) -> str: + t = T(lang, view.year.year, view.year.department.name) + cats = [SimpleNamespace(name=cv.name(lang), category=cv.category, lines=cv.lines, totals=cv.totals) + for cv in view.categories if cv.lines or include_empty] + # revenue first, as in the Budget IT sheet + cats.sort(key=lambda c: 0 if c.category.section == "revenue" else 1) + return env.get_template("budget.html").render( + t=t, title=t("budget_title"), year=view.year, site=site, cats=cats, costs=view.costs, + exported=_exported(), css=CSS) + + +def to_pdf(html: str) -> bytes: + from weasyprint import HTML # imported lazily: it loads system libraries + + return HTML(string=html).write_pdf() diff --git a/budget-app/backend/app/reports/templates/budget.html b/budget-app/backend/app/reports/templates/budget.html new file mode 100644 index 0000000..8bbec91 --- /dev/null +++ b/budget-app/backend/app/reports/templates/budget.html @@ -0,0 +1,25 @@ + + + +{{ title }} + +
+

{{ title }}

+
{{ t('budget_sub') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %} · {{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}
+ + {% for m in t('months') %}{% endfor %} + + {% for c in cats %} + + {% for l in c.lines %} + + {% for m in l.figures.months %}{% endfor %} + {% endfor %} + + {% for m in c.totals.months %}{% endfor %} + {% endfor %} + {% for m in costs.months %}{% endfor %} + +
{{ t('line') }}{{ t('site') }}{{ m }}{{ t("total") }} (€)
{{ c.name }}
{{ l.line.name }}{{ l.line.site.name if l.line.site else t('group') }}{{ t.num(m) }}{{ t.num(l.figures.adjusted) }}
{{ t('revenue_total') if c.category.section == 'revenue' else t('sub_total', name=c.name) }}{{ t.num(m) }}{{ t.num(c.totals.adjusted) }}
{{ t('total_costs') }}{{ t.num(m) }}{{ t.num(costs.adjusted) }}
+
+ diff --git a/budget-app/backend/app/reports/templates/cost_report.html b/budget-app/backend/app/reports/templates/cost_report.html new file mode 100644 index 0000000..44947c6 --- /dev/null +++ b/budget-app/backend/app/reports/templates/cost_report.html @@ -0,0 +1,75 @@ + + + +{{ title }} +{% if screen %}{% endif %} + + +{% set total_pages = 3 + cats|length %} +
+

{{ title }}

+

{{ t('cost_report_subtitle') }}{% if site %} · {{ t('site_filter', site=site) }}{% endif %}

+
{{ t('total_across', n=cats|length) }}
+
{{ t.eur(costs.prior, 0) }}  →  {{ t.eur(costs.adjusted, 0) }}
+
{{ t.pct(costs.change_pct) }}  ({{ t.eur(costs.change, 0, true) }})
+
{{ t('status') }}: {{ t(year.status) }} · {{ t('exported') }}: {{ exported }}
+
+ +
+

{{ t('by_category') }}

+

{{ t('by_category_sub') }}

+
+
{{ t('prior_actual') }}{{ t('adjusted') }}
+
{{ overview_chart|safe }}
+ +
+ +
+

{{ t('change_by_category') }}

+

{{ t('change_by_category_sub') }}

+
+
{{ t('change_legend') }}
+
{{ change_chart|safe }}
+ +
+ +{% for c in cats %} +
+

{{ c.name }}

+

{{ t('cost_line') if c.count == 1 else t('cost_lines', n=c.count) }}

+
+
{{ t('prior_actual') }}{{ t('adjusted') }}
+
+
{{ c.chart|safe }}
+
+
{{ t('prior_actual') }}
{{ t.eur(c.totals.prior) }}
+
{{ t('adjusted') }}
{{ t.eur(c.totals.adjusted) }}
+
{{ t('change_eur') }}
{{ t.eur(c.totals.change, 2, true) }}
+
{{ t('change_pct') }}
{{ t.pct(c.totals.change_pct) }}
+
+
+ +
+{% endfor %} + +
+

{{ t('summary') }}

+

{{ t('summary_sub') }}

+
+ + + + {% for c in cats %} + + + {% endfor %} + + + +
{{ t('category') }}{{ t('prior_actual') }}{{ t('adjusted') }}{{ t('change_eur') }}{{ t('change_pct') }}
{{ c.name }}{{ t.eur(c.totals.prior) }}{{ t.eur(c.totals.adjusted) }}{{ t.eur(c.totals.change, 2, true) }}{{ t.pct(c.totals.change_pct) }}
{{ t('total') }}{{ t.eur(costs.prior) }}{{ t.eur(costs.adjusted) }}{{ t.eur(costs.change, 2, true) }}{{ t.pct(costs.change_pct) }}
+ +
+ diff --git a/budget-app/backend/app/reports/templates/report.css b/budget-app/backend/app/reports/templates/report.css new file mode 100644 index 0000000..454850f --- /dev/null +++ b/budget-app/backend/app/reports/templates/report.css @@ -0,0 +1,62 @@ +@page { size: 338.67mm 190.5mm; margin: 0; } +@page budget { size: A3 landscape; margin: 12mm 10mm 14mm 10mm; + @bottom-left { content: string(doc-title); font: 8pt Helvetica, Arial, sans-serif; color: #888; } + @bottom-right { content: counter(page) " / " counter(pages); font: 8pt Helvetica, Arial, sans-serif; color: #888; } } +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { font-family: "DejaVu Sans", "Liberation Sans", Arial, sans-serif; color: #1d1d1f; background: #ececee; } +.page { position: relative; width: 338.67mm; height: 190.5mm; padding: 13mm 16mm 12mm; background: #fbfbf9; + overflow: hidden; page-break-after: always; break-after: page; } +.page:last-child { page-break-after: auto; break-after: auto; } +.page h1 { font-size: 22pt; margin: 0 0 1.5mm; } +.page .sub { font-size: 10pt; color: #444; margin: 0 0 3mm; } +.rule { border-top: 1px solid #ddd; margin: 2mm 0 3mm; } +.legend { font-size: 8.5pt; color: #333; margin-bottom: 2mm; } +.legend span { display: inline-block; margin-right: 18mm; } +.legend i { display: inline-block; width: 3mm; height: 3mm; margin-right: 2mm; vertical-align: -0.4mm; } +.footer { position: absolute; left: 16mm; right: 16mm; bottom: 6mm; font-size: 7pt; color: #888; } +.footer .n { float: right; } +.cover { padding-left: 22mm; } +.cover::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4mm; background: #2f6fd6; } +.cover h1 { font-size: 34pt; margin-top: 48mm; } +.cover .sub { font-size: 13pt; } +.cover .kicker { margin-top: 16mm; font-size: 10pt; color: #444; } +.cover .big { font-size: 22pt; font-weight: bold; margin: 2mm 0; } +.cover .delta { font-size: 13pt; font-weight: bold; } +.up { color: #c2410c; } .down { color: #15803d; } +.body { display: flex; gap: 8mm; } +.chartbox { flex: 1 1 auto; height: 128mm; } +.chartbox.full { height: 136mm; } +.chartbox svg { width: 100%; height: 100%; } +.kpis { flex: 0 0 72mm; padding-top: 2mm; } +.kpi { margin-bottom: 7mm; } +.kpi .k { font-size: 9pt; color: #333; } +.kpi .v { font-size: 18pt; font-weight: bold; margin-top: 1.5mm; } +table.summary { width: 100%; border-collapse: collapse; font-size: 9pt; margin-top: 3mm; } +table.summary th { text-align: left; font-weight: bold; border-bottom: 1px solid #bbb; padding: 2mm; } +table.summary td { padding: 1.6mm 2mm; border-bottom: 1px solid #e4e4e4; } +table.summary .num { text-align: right; white-space: nowrap; } +table.summary tr.total td { font-weight: bold; border-top: 1px solid #999; } +.meta { font-size: 8pt; color: #777; margin-top: 2mm; } + +/* annual budget */ +.budget { page: budget; background: #fff; padding: 0; width: auto; height: auto; overflow: visible; } +.budget h1 { font-size: 16pt; margin: 0 0 1mm; string-set: doc-title content(); } +.budget .sub { font-size: 9pt; color: #555; margin-bottom: 4mm; } +table.grid { width: 100%; border-collapse: collapse; font-size: 7.2pt; } +table.grid th, table.grid td { padding: 1.1mm 1.2mm; border-bottom: 1px solid #eee; } +table.grid thead th { background: #1f3b63; color: #fff; text-align: right; font-weight: bold; } +table.grid thead th:first-child, table.grid thead th:nth-child(2) { text-align: left; } +table.grid thead { display: table-header-group; } +table.grid td.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } +table.grid tr.cat td { font-weight: bold; background: #eef2f8; padding-top: 2mm; } +table.grid tr.subtotal td { font-weight: bold; border-top: 1px solid #9aa; border-bottom: 1px solid #9aa; } +table.grid tr.grand td { font-weight: bold; background: #1f3b63; color: #fff; } +table.grid tr { page-break-inside: avoid; } +table.grid td.name { max-width: 70mm; } + +@media screen { + body { padding: 16px 0; } + .page { margin: 0 auto 16px; box-shadow: 0 1px 4px rgba(0,0,0,.15); zoom: var(--zoom, 1); } + .budget { width: auto; max-width: 1600px; height: auto; padding: 16px 20px; margin: 0 auto; overflow: visible; } +} diff --git a/budget-app/backend/app/routers/__init__.py b/budget-app/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/budget-app/backend/app/routers/activity.py b/budget-app/backend/app/routers/activity.py new file mode 100644 index 0000000..c7256c3 --- /dev/null +++ b/budget-app/backend/app/routers/activity.py @@ -0,0 +1,76 @@ +"""The activity log: everything users did, newest first (administrators).""" +import csv +import io + +from fastapi import APIRouter, Depends +from fastapi.responses import Response +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from ..db import get_db +from ..models import AuditEntry, BudgetYear, Department, User +from ..security import require +from .mail import _date + +router = APIRouter(prefix="/api", tags=["activity"]) + + +def _query(q, user_id, entity, action, year_id, date_from, date_to): + query = select(AuditEntry) + if user_id: + query = query.where(AuditEntry.user_id == user_id) + if entity: + query = query.where(AuditEntry.entity == entity) + if action: + query = query.where(AuditEntry.action == action) + if year_id: + query = query.where(AuditEntry.year_id == year_id) + if date_from: + query = query.where(AuditEntry.at >= _date(date_from)) + if date_to: + query = query.where(AuditEntry.at < _date(date_to, end=True)) + if q: + like = f"%{q.strip().lower()}%" + query = query.where(or_(func.lower(AuditEntry.summary).like(like), + AuditEntry.user_id.in_(select(User.id).where(func.lower(User.name).like(like))))) + return query + + +def _rows(db: Session, entries: list[AuditEntry]) -> list[dict]: + year_ids = {a.year_id for a in entries if a.year_id} + labels = {y.id: f"{d.name} {y.year}" for y, d in db.execute( + select(BudgetYear, Department).join(Department).where(BudgetYear.id.in_(year_ids)))} if year_ids else {} + return [{"id": a.id, "at": a.at.isoformat(), "user": a.user.name if a.user else None, "user_id": a.user_id, + "entity": a.entity, "entity_id": a.entity_id, "action": a.action, "summary": a.summary, + "changes": a.changes, "year_id": a.year_id, + "budget": labels.get(a.year_id) if a.year_id else None, + "budget_exists": a.year_id in labels} for a in entries] + + +@router.get("/activity") +def activity(q: str | None = None, user_id: int | None = None, entity: str | None = None, action: str | None = None, + year_id: int | None = None, date_from: str | None = None, date_to: str | None = None, + limit: int = 100, offset: int = 0, db: Session = Depends(get_db), _: User = Depends(require("admin"))): + query = _query(q, user_id, entity, action, year_id, date_from, date_to) + total = db.scalar(select(func.count()).select_from(query.subquery())) + entries = db.scalars(query.order_by(AuditEntry.at.desc(), AuditEntry.id.desc()) + .limit(min(max(limit, 1), 500)).offset(max(offset, 0))).all() + kinds = db.execute(select(AuditEntry.entity, AuditEntry.action).distinct()).all() + return {"total": total, "rows": _rows(db, entries), + "entities": sorted({e for e, _ in kinds}), "actions": sorted({a for _, a in kinds})} + + +@router.get("/activity.csv") +def activity_csv(q: str | None = None, user_id: int | None = None, entity: str | None = None, + action: str | None = None, year_id: int | None = None, date_from: str | None = None, + date_to: str | None = None, db: Session = Depends(get_db), _: User = Depends(require("admin"))): + entries = db.scalars(_query(q, user_id, entity, action, year_id, date_from, date_to) + .order_by(AuditEntry.at.desc(), AuditEntry.id.desc())).all() + out = io.StringIO() + w = csv.writer(out, delimiter=";") + w.writerow(["date", "user", "area", "action", "budget", "summary", "changes"]) + for r in _rows(db, entries): + w.writerow([r["at"], r["user"] or "", r["entity"], r["action"], r["budget"] or "", r["summary"], + "" if not r["changes"] else str(r["changes"])]) + return Response("" + out.getvalue(), media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="activity_log.csv"'}) diff --git a/budget-app/backend/app/routers/admin.py b/budget-app/backend/app/routers/admin.py new file mode 100644 index 0000000..cb0cf36 --- /dev/null +++ b/budget-app/backend/app/routers/admin.py @@ -0,0 +1,398 @@ +"""Departments and their workflows, users, sites and categories. + +Reading is open to every signed-in user (except the user list). Departments, workflows, users and +sites are managed by administrators; a department's categories also by that department's approvers. +""" +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel, Field +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from .. import mfa +from ..audit import diff, record, snapshot +from ..bootstrap import seed_categories +from ..db import get_db +from ..models import BudgetYear, Category, CostLine, Department, Site, User, WorkflowStep +from ..permissions import is_admin, member +from ..security import ROLES, current_user, hash_password, require +from ..serializers import category_out, department_out, site_out, user_out +from ..summary import build_year_view +from ..template import build_template + +router = APIRouter(prefix="/api", tags=["admin"]) + + +class UserIn(BaseModel): + department_id: int | None = None + email: str | None = None + name: str | None = None + role: str | None = None + language: str | None = None + active: bool | None = None + password: str | None = None + + +@router.get("/users") +def list_users(db: Session = Depends(get_db), _: User = Depends(require("admin"))): + return [user_out(u) for u in db.scalars(select(User).order_by(User.name))] + + +def _apply_user(u: User, body: UserIn, db: Session): + if "department_id" in body.model_fields_set: + if body.department_id is not None and not db.get(Department, body.department_id): + raise HTTPException(400, "invalid_department") + u.department_id = body.department_id + if body.email is not None: + email = body.email.strip().lower() + if "@" not in email: + raise HTTPException(400, "invalid_email") + clash = db.scalar(select(User).where(func.lower(User.email) == email, User.id != (u.id or 0))) + if clash: + raise HTTPException(400, "email_taken") + u.email = email + if body.name is not None: + u.name = body.name.strip() + if body.role is not None: + if body.role not in ROLES: + raise HTTPException(400, "invalid_role") + u.role = body.role + if body.language in ("pt", "en"): + u.language = body.language + if body.active is not None: + u.active = body.active + if body.password: + if len(body.password) < 8: + raise HTTPException(400, "password_too_short") + u.password_hash = hash_password(body.password) + + +@router.post("/users") +def create_user(body: UserIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))): + if not body.email or not body.name or not body.password: + raise HTTPException(400, "missing_fields") + u = User(role="viewer", language="pt", active=True, password_hash="") + _apply_user(u, body, db) + db.add(u) + db.flush() + record(db, admin, "user", u.id, "create", f"{u.name} <{u.email}> as {u.role}") + db.commit() + return user_out(u) + + +@router.put("/users/{user_id}") +def update_user(user_id: int, body: UserIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))): + u = db.get(User, user_id) + if not u: + raise HTTPException(404, "not_found") + if u.id == admin.id and (body.role not in (None, "admin") or body.active is False): + raise HTTPException(400, "cannot_demote_self") + fields = ["email", "name", "role", "active", "department_id"] + before = snapshot(u, fields) + _apply_user(u, body, db) + changes = diff(before, snapshot(u, fields)) + if body.password: + changes["password"] = ["", "reset"] + record(db, admin, "user", u.id, "update", u.name, changes) + db.commit() + return user_out(u) + + +@router.post("/users/{user_id}/mfa/reset") +def reset_user_mfa(user_id: int, db: Session = Depends(get_db), admin: User = Depends(require("admin"))): + """For a user who lost their phone: MFA goes off and they can set it up again after signing in.""" + u = db.get(User, user_id) + if not u: + raise HTTPException(404, "not_found") + mfa.disable(u) + record(db, admin, "user", u.id, "mfa_reset", u.name) + db.commit() + return user_out(u) + + +@router.get("/users/{user_id}/avatar") +def user_avatar(user_id: int, db: Session = Depends(get_db), _: User = Depends(current_user)): + u = db.get(User, user_id) + if not u or not u.avatar: + raise HTTPException(404, "not_found") + return Response(u.avatar, media_type=u.avatar_type or "image/jpeg", + headers={"Cache-Control": "private, max-age=31536000, immutable"}) + + +class SiteIn(BaseModel): + name: str | None = Field(default=None, max_length=120) + active: bool | None = None + + +@router.get("/sites") +def list_sites(db: Session = Depends(get_db), _: User = Depends(current_user)): + return [site_out(s) for s in db.scalars(select(Site).order_by(Site.name))] + + +@router.post("/sites") +def create_site(body: SiteIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))): + if not body.name or not body.name.strip(): + raise HTTPException(400, "missing_fields") + if db.scalar(select(Site).where(func.lower(Site.name) == body.name.strip().lower())): + raise HTTPException(400, "name_taken") + s = Site(name=body.name.strip(), active=True) + db.add(s) + db.flush() + record(db, admin, "site", s.id, "create", s.name) + db.commit() + return site_out(s) + + +@router.put("/sites/{site_id}") +def update_site(site_id: int, body: SiteIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))): + s = db.get(Site, site_id) + if not s: + raise HTTPException(404, "not_found") + if body.name: + s.name = body.name.strip() + if body.active is not None: + s.active = body.active + record(db, admin, "site", s.id, "update", s.name) + db.commit() + return site_out(s) + + +class CategoryIn(BaseModel): + department_id: int | None = None + name_en: str | None = Field(default=None, max_length=200) + name_pt: str | None = Field(default=None, max_length=200) + section: str | None = None + sort_order: int | None = None + show_when_empty: bool | None = None + active: bool | None = None + + +def can_manage_categories(user: User, department_id: int) -> bool: + return is_admin(user) or (member(user, department_id) and ROLES.index(user.role) >= ROLES.index("approver")) + + +def _require_categories(user: User, department_id: int) -> None: + if not can_manage_categories(user, department_id): + raise HTTPException(403, "forbidden") + + +@router.get("/categories") +def list_categories(department_id: int | None = None, db: Session = Depends(get_db), _: User = Depends(current_user)): + q = select(Category).order_by(Category.department_id, Category.sort_order, Category.id) + if department_id: + q = q.where(Category.department_id == department_id) + return [category_out(c) for c in db.scalars(q)] + + +def _apply_category(c: Category, body: CategoryIn, db: Session): + if body.name_en is not None: + name = body.name_en.strip() + if not name: + raise HTTPException(400, "missing_fields") + if db.scalar(select(Category).where(Category.department_id == c.department_id, + func.lower(Category.name_en) == name.lower(), Category.id != (c.id or 0))): + raise HTTPException(400, "name_taken") + c.name_en = name + if body.name_pt is not None: + c.name_pt = body.name_pt.strip() + if body.section is not None: + if body.section not in ("cost", "revenue"): + raise HTTPException(400, "invalid_section") + c.section = body.section + for f in ("sort_order", "show_when_empty", "active"): + if getattr(body, f) is not None: + setattr(c, f, getattr(body, f)) + + +@router.post("/categories") +def create_category(body: CategoryIn, db: Session = Depends(get_db), admin: User = Depends(current_user)): + if not body.name_en or not body.department_id or not db.get(Department, body.department_id): + raise HTTPException(400, "missing_fields") + _require_categories(admin, body.department_id) + top = db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == body.department_id)) + c = Category(department_id=body.department_id, section="cost", name_pt="", show_when_empty=True, active=True, + sort_order=(top or 0) + 10) + _apply_category(c, body, db) + db.add(c) + db.flush() + record(db, admin, "category", c.id, "create", c.name_en) + db.commit() + return category_out(c) + + +@router.put("/categories/{cat_id}") +def update_category(cat_id: int, body: CategoryIn, db: Session = Depends(get_db), admin: User = Depends(current_user)): + c = db.get(Category, cat_id) + if not c: + raise HTTPException(404, "not_found") + _require_categories(admin, c.department_id) + _apply_category(c, body, db) + record(db, admin, "category", c.id, "update", c.name_en) + db.commit() + return category_out(c) + + +@router.delete("/categories/{cat_id}") +def delete_category(cat_id: int, db: Session = Depends(get_db), admin: User = Depends(current_user)): + c = db.get(Category, cat_id) + if not c: + raise HTTPException(404, "not_found") + _require_categories(admin, c.department_id) + if db.scalar(select(func.count(CostLine.id)).where(CostLine.category_id == cat_id)): + raise HTTPException(400, "category_in_use") + record(db, admin, "category", c.id, "delete", c.name_en) + db.delete(c) + db.commit() + return {"ok": True} + + +class DepartmentIn(BaseModel): + name: str | None = Field(default=None, max_length=120) + active: bool | None = None + # where the new department's categories come from: "defaults", "none" or another department's id + categories_from: str | int | None = "defaults" + + +@router.get("/departments") +def list_departments(db: Session = Depends(get_db), _: User = Depends(current_user)): + return [department_out(d) for d in db.scalars(select(Department).order_by(Department.name))] + + +def _copy_categories(db: Session, target: Department, source) -> None: + if source in (None, "none", ""): + return + if source == "defaults": + seed_categories(db, target) + return + src = db.get(Department, int(source)) + if not src: + raise HTTPException(400, "invalid_department") + existing = {n.lower() for n in db.scalars(select(Category.name_en).where(Category.department_id == target.id))} + for c in db.scalars(select(Category).where(Category.department_id == src.id).order_by(Category.sort_order)): + if c.name_en.lower() not in existing: + db.add(Category(department_id=target.id, name_en=c.name_en, name_pt=c.name_pt, section=c.section, + sort_order=c.sort_order, show_when_empty=c.show_when_empty, active=c.active)) + + +@router.post("/departments") +def create_department(body: DepartmentIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))): + name = (body.name or "").strip() + if not name: + raise HTTPException(400, "missing_fields") + if db.scalar(select(Department).where(func.lower(Department.name) == name.lower())): + raise HTTPException(400, "name_taken") + d = Department(name=name, active=True) + db.add(d) + db.flush() + _copy_categories(db, d, body.categories_from) + record(db, admin, "department", d.id, "create", d.name) + db.commit() + db.refresh(d) + return department_out(d) + + +@router.put("/departments/{dept_id}") +def update_department(dept_id: int, body: DepartmentIn, db: Session = Depends(get_db), + admin: User = Depends(require("admin"))): + d = db.get(Department, dept_id) + if not d: + raise HTTPException(404, "not_found") + if body.name and body.name.strip() != d.name: + if db.scalar(select(Department).where(func.lower(Department.name) == body.name.strip().lower())): + raise HTTPException(400, "name_taken") + d.name = body.name.strip() + if body.active is not None: + d.active = body.active + record(db, admin, "department", d.id, "update", d.name) + db.commit() + return department_out(d) + + +class CopyCategoriesIn(BaseModel): + source: str | int + + +@router.post("/departments/{dept_id}/categories/copy") +def copy_categories(dept_id: int, body: CopyCategoriesIn, db: Session = Depends(get_db), + user: User = Depends(current_user)): + d = db.get(Department, dept_id) + if not d: + raise HTTPException(404, "not_found") + _require_categories(user, d.id) + if body.source == "defaults": + existing = {n.lower() for n in db.scalars(select(Category.name_en).where(Category.department_id == d.id))} + from ..seed import CATEGORIES + top = db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == d.id)) or 0 + for i, (en, pt, section) in enumerate(CATEGORIES): + if en.lower() not in existing: + db.add(Category(department_id=d.id, name_en=en, name_pt=pt, section=section, sort_order=top + (i + 1) * 10)) + else: + _copy_categories(db, d, body.source) + record(db, user, "department", d.id, "copy_categories", f"from {body.source}") + db.commit() + return {"ok": True} + + +class StepIn(BaseModel): + name: str = Field(max_length=120) + approver_mode: str = "role" + approver_ids: list[int] = [] + + +class WorkflowIn(BaseModel): + steps: list[StepIn] + + +@router.put("/departments/{dept_id}/workflow") +def set_workflow(dept_id: int, body: WorkflowIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))): + d = db.get(Department, dept_id) + if not d: + raise HTTPException(404, "not_found") + if db.scalar(select(func.count(BudgetYear.id)).where(BudgetYear.department_id == d.id, + BudgetYear.status == "in_review")): + raise HTTPException(409, "workflow_in_use") + if len(body.steps) > 10: + raise HTTPException(400, "too_many_steps") + valid_users = set(db.scalars(select(User.id).where(User.role != "management"))) # Upper Management never approves + before = [s.name for s in d.steps] + d.steps.clear() + db.flush() + for i, step in enumerate(body.steps): + if not step.name.strip() or step.approver_mode not in ("role", "users"): + raise HTTPException(400, "invalid_step") + ids = [u for u in step.approver_ids if u in valid_users] if step.approver_mode == "users" else [] + if step.approver_mode == "users" and not ids: + raise HTTPException(400, "step_needs_approvers") + d.steps.append(WorkflowStep(position=i, name=step.name.strip(), approver_mode=step.approver_mode, + approver_ids=ids)) + record(db, admin, "department", d.id, "workflow", d.name, {"steps": [before, [s.name for s in body.steps]]}) + db.commit() + db.refresh(d) + return department_out(d) + + +XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + + +@router.get("/departments/{dept_id}/template.xlsx") +def download_template(dept_id: int, lang: str | None = None, year: int | None = None, rate: float | None = None, + from_year_id: int | None = None, db: Session = Depends(get_db), user: User = Depends(current_user)): + from datetime import date + from decimal import Decimal + + d = db.get(Department, dept_id) + if not d: + raise HTTPException(404, "not_found") + lang = lang if lang in ("pt", "en") else user.language + lines = None + source = db.get(BudgetYear, from_year_id) if from_year_id else None + if source and source.department_id == d.id: + lines = [lv for cv in build_year_view(db, source).categories for lv in cv.lines] + year = year or (source.year + 1 if source else date.today().year + 1) + rate_value = Decimal(str(rate)) / 100 if rate is not None else (source.default_rate if source else Decimal(0)) + data = build_template(db, d, lang, year, rate_value, lines) + safe = "".join(ch if ch.isalnum() else "_" for ch in d.name) + name = ("Modelo_Orcamento" if lang == "pt" else "Budget_Template") + f"_{safe}_{year}.xlsx" + record(db, user, "template", d.id, "download", name) + db.commit() + return Response(data, media_type=XLSX, headers={"Content-Disposition": f'attachment; filename="{name}"'}) diff --git a/budget-app/backend/app/routers/auth.py b/budget-app/backend/app/routers/auth.py new file mode 100644 index 0000000..bb54a17 --- /dev/null +++ b/budget-app/backend/app/routers/auth.py @@ -0,0 +1,208 @@ +from datetime import datetime, timezone +from io import BytesIO + +from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from .. import mfa +from ..audit import record +from ..config import settings +from ..db import get_db +from ..models import User +from ..security import COOKIE, current_user, hash_password, make_token, verify_password +from ..serializers import user_out + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class LoginIn(BaseModel): + email: str + password: str + code: str | None = None # authenticator or recovery code, for users with MFA on + + +class ProfileIn(BaseModel): + name: str | None = None + language: str | None = None + theme: str | None = None + current_password: str | None = None + new_password: str | None = None + + +@router.post("/login") +def login(body: LoginIn, response: Response, db: Session = Depends(get_db)): + user = db.scalar(select(User).where(func.lower(User.email) == body.email.strip().lower())) + if not user or not user.active or not verify_password(body.password, user.password_hash): + record(db, user if user and user.active else None, "user", user.id if user else None, "login_failed", + body.email.strip()[:200]) + db.commit() + raise HTTPException(401, "invalid_credentials") + if user.mfa_enabled: + if not (body.code or "").strip(): + # password is right: the sign-in screen now asks for the code + return {"mfa_required": True} + if mfa.locked(user): + raise HTTPException(429, "mfa_locked") + kind = mfa.check(user, body.code) + if not kind: + record(db, user, "user", user.id, "mfa_failed", user.email) + db.commit() + raise HTTPException(429 if mfa.locked(user) else 401, "mfa_locked" if mfa.locked(user) else "invalid_mfa_code") + if kind == "recovery": + record(db, user, "user", user.id, "mfa_recovery_used", f"{len(user.mfa_recovery or [])} recovery codes left") + response.set_cookie(COOKIE, make_token(user), httponly=True, samesite="lax", + secure=settings.cookie_secure, max_age=settings.session_hours * 3600) + record(db, user, "user", user.id, "login") + db.commit() + return user_out(user) + + +@router.post("/logout") +def logout(request: Request, response: Response, db: Session = Depends(get_db)): + try: + user = current_user(request, db) + record(db, user, "user", user.id, "logout") + db.commit() + except HTTPException: + pass + response.delete_cookie(COOKIE) + return {"ok": True} + + +@router.get("/me") +def me(user: User = Depends(current_user)): + return user_out(user) + + +@router.put("/me") +def update_me(body: ProfileIn, user: User = Depends(current_user), db: Session = Depends(get_db)): + if body.name: + user.name = body.name.strip() + if body.language in ("pt", "en"): + user.language = body.language + if body.theme in ("system", "light", "dark"): + user.theme = body.theme + if body.new_password: + if not body.current_password or not verify_password(body.current_password, user.password_hash): + raise HTTPException(400, "wrong_password") + if len(body.new_password) < 8: + raise HTTPException(400, "password_too_short") + user.password_hash = hash_password(body.new_password) + record(db, user, "user", user.id, "password_changed") + db.commit() + return user_out(user) + + +# --- MFA ---------------------------------------------------------------------------------------- + +class CodeIn(BaseModel): + code: str = "" + password: str = "" + + +@router.post("/mfa/setup") +def mfa_setup(user: User = Depends(current_user), db: Session = Depends(get_db)): + """Starts MFA set-up: a new secret to scan as a QR code. MFA is only on after a code is confirmed.""" + if user.mfa_enabled: + raise HTTPException(409, "mfa_already_on") + secret = mfa.new_secret() + user.mfa_pending_secret = mfa.seal(secret) + db.commit() + uri = mfa.otpauth_uri(secret, user.email) + return {"secret": secret, "uri": uri, "qr_svg": mfa.qr_svg(uri)} + + +@router.post("/mfa/enable") +def mfa_enable(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)): + secret = mfa.unseal(user.mfa_pending_secret) + if user.mfa_enabled or not secret: + raise HTTPException(409, "mfa_setup_first") + step = mfa.matching_step(secret, body.code) + if step is None: + raise HTTPException(400, "invalid_mfa_code") + codes, hashes = mfa.new_recovery_codes() + user.mfa_secret, user.mfa_pending_secret, user.mfa_enabled = user.mfa_pending_secret, None, True + user.mfa_enabled_at, user.mfa_last_step, user.mfa_recovery = datetime.now(timezone.utc), step, hashes + user.mfa_failed, user.mfa_locked_until = 0, None + record(db, user, "user", user.id, "mfa_enabled", user.email) + db.commit() + return {"user": user_out(user), "recovery_codes": codes} + + +@router.post("/mfa/recovery-codes") +def mfa_new_recovery_codes(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)): + """New recovery codes (the old ones stop working). Needs a current code.""" + if not user.mfa_enabled: + raise HTTPException(409, "mfa_off") + if not mfa.check(user, body.code): + db.commit() + raise HTTPException(400, "invalid_mfa_code") + codes, user.mfa_recovery = mfa.new_recovery_codes() + record(db, user, "user", user.id, "mfa_recovery_renewed", user.email) + db.commit() + return {"user": user_out(user), "recovery_codes": codes} + + +@router.post("/mfa/disable") +def mfa_disable(body: CodeIn, user: User = Depends(current_user), db: Session = Depends(get_db)): + """Turning MFA off needs the password and a current code (or a recovery code).""" + if not user.mfa_enabled: + raise HTTPException(409, "mfa_off") + if not verify_password(body.password, user.password_hash): + raise HTTPException(400, "wrong_password") + if not mfa.check(user, body.code): + db.commit() + raise HTTPException(400, "invalid_mfa_code") + mfa.disable(user) + record(db, user, "user", user.id, "mfa_disabled", user.email) + db.commit() + return user_out(user) + + +# --- profile picture ---------------------------------------------------------------------------- + +AVATAR_MAX_BYTES = 5 * 1024 * 1024 +AVATAR_SIZE = 256 + + +def resize_avatar(data: bytes) -> tuple[bytes, str]: + """Any common picture -> a square JPEG (or PNG when it has transparency) of at most 256 px.""" + from PIL import Image, ImageOps, UnidentifiedImageError + try: + img = Image.open(BytesIO(data)) + img.load() + except (UnidentifiedImageError, OSError, Image.DecompressionBombError): + raise HTTPException(400, "not_an_image") + img = ImageOps.exif_transpose(img) + side = min(img.size) + left, top = (img.width - side) // 2, (img.height - side) // 2 + img = img.crop((left, top, left + side, top + side)) + img.thumbnail((AVATAR_SIZE, AVATAR_SIZE)) + out = BytesIO() + if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info): + img.convert("RGBA").save(out, "PNG", optimize=True) + return out.getvalue(), "image/png" + img.convert("RGB").save(out, "JPEG", quality=88, optimize=True) + return out.getvalue(), "image/jpeg" + + +@router.put("/me/avatar") +async def upload_avatar(file: UploadFile = File(...), user: User = Depends(current_user), db: Session = Depends(get_db)): + data = await file.read(AVATAR_MAX_BYTES + 1) + if len(data) > AVATAR_MAX_BYTES: + raise HTTPException(413, "image_too_large") + user.avatar, user.avatar_type = resize_avatar(data) + user.avatar_updated_at = datetime.now(timezone.utc) + record(db, user, "user", user.id, "avatar_changed", user.email) + db.commit() + return user_out(user) + + +@router.delete("/me/avatar") +def delete_avatar(user: User = Depends(current_user), db: Session = Depends(get_db)): + user.avatar = user.avatar_type = user.avatar_updated_at = None + record(db, user, "user", user.id, "avatar_removed", user.email) + db.commit() + return user_out(user) diff --git a/budget-app/backend/app/routers/mail.py b/budget-app/backend/app/routers/mail.py new file mode 100644 index 0000000..406f2e1 --- /dev/null +++ b/budget-app/backend/app/routers/mail.py @@ -0,0 +1,246 @@ +"""Mail account settings and the mail log (administrators), and sending reports by email (everyone).""" +import csv +import io +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +from pydantic import BaseModel, Field +from sqlalchemy import String, cast, func, or_, select +from sqlalchemy.orm import Session + +from .. import mailer +from ..audit import diff, record, snapshot +from ..db import get_db +from ..models import MailLog, MailSettings, User +from ..security import current_user, require +from .reports import FORMATS, KINDS, MIME, render_report, report_lang, workbook_file +from .years import get_year + +router = APIRouter(prefix="/api", tags=["mail"]) + +PLAIN_FIELDS = ["enabled", "smtp_host", "smtp_port", "smtp_security", "smtp_username", "from_address", "from_name", + "reply_to", "signature", "save_to_sent", "imap_host", "imap_port", "imap_security", + "imap_same_login", "imap_username", "sent_folder"] +SECURITY = ("starttls", "ssl", "none") +MAX_ATTACHMENTS_BYTES = 20 * 1024 * 1024 + + +def settings_out(cfg: MailSettings) -> dict: + out = {f: getattr(cfg, f) for f in PLAIN_FIELDS} + out.update(smtp_password_set=bool(cfg.smtp_password), imap_password_set=bool(cfg.imap_password), + ready=mailer.is_ready(cfg), updated_at=cfg.updated_at.isoformat() if cfg.updated_at else None, + updated_by=cfg.updated_by.name if cfg.updated_by else None) + return out + + +class MailSettingsIn(BaseModel): + enabled: bool | None = None + smtp_host: str | None = Field(default=None, max_length=255) + smtp_port: int | None = Field(default=None, ge=1, le=65535) + smtp_security: str | None = None + smtp_username: str | None = Field(default=None, max_length=255) + smtp_password: str | None = Field(default=None, max_length=500) # empty or missing: keep the saved one + from_address: str | None = Field(default=None, max_length=255) + from_name: str | None = Field(default=None, max_length=255) + reply_to: str | None = Field(default=None, max_length=255) + signature: str | None = Field(default=None, max_length=4000) + save_to_sent: bool | None = None + imap_host: str | None = Field(default=None, max_length=255) + imap_port: int | None = Field(default=None, ge=1, le=65535) + imap_security: str | None = None + imap_same_login: bool | None = None + imap_username: str | None = Field(default=None, max_length=255) + imap_password: str | None = Field(default=None, max_length=500) + sent_folder: str | None = Field(default=None, max_length=255) + + +@router.get("/mail/settings") +def read_settings(db: Session = Depends(get_db), _: User = Depends(require("admin"))): + cfg = mailer.get_settings(db) + db.commit() + return settings_out(cfg) + + +@router.put("/mail/settings") +def save_settings(body: MailSettingsIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))): + cfg = mailer.get_settings(db) + before = snapshot(cfg, PLAIN_FIELDS) + data = body.model_dump(exclude_unset=True) + for key in ("smtp_security", "imap_security"): + if key in data and data[key] not in SECURITY: + raise HTTPException(400, "invalid_security") + for key in ("from_address", "reply_to"): + if data.get(key): + try: + mailer.clean_addresses([data[key]]) + except mailer.MailError: + raise HTTPException(400, "invalid_address") + for key in PLAIN_FIELDS: + if key in data and data[key] is not None: + setattr(cfg, key, data[key].strip() if isinstance(data[key], str) and key != "signature" else data[key]) + changed_passwords = [] + for key in ("smtp_password", "imap_password"): + if data.get(key): + setattr(cfg, key, mailer.encrypt(data[key])) + changed_passwords.append(key) + if cfg.enabled and not (cfg.smtp_host and cfg.from_address): + raise HTTPException(400, "mail_incomplete") + cfg.updated_at, cfg.updated_by_id = datetime.now(timezone.utc), admin.id + changes = diff(before, snapshot(cfg, PLAIN_FIELDS)) + for key in changed_passwords: + changes[key] = ["•••", "••• (new)"] + record(db, admin, "mail_settings", 1, "update", ", ".join(changes) or "no changes", changes) + db.commit() + return settings_out(cfg) + + +class TestIn(BaseModel): + to: str | None = None + + +@router.post("/mail/test") +def test_mail(body: TestIn, db: Session = Depends(get_db), admin: User = Depends(require("admin"))): + """Without an address: sign in only. With one: also send a short test message (logged).""" + cfg = mailer.get_settings(db) + if not (cfg.smtp_host and cfg.from_address): + raise HTTPException(400, "mail_incomplete") + if not body.to: + result = mailer.check(cfg) + record(db, admin, "mail_settings", 1, "check", f"smtp {result['smtp']}; imap {result['imap']}") + db.commit() + return result + try: + to = mailer.clean_addresses([body.to]) + except mailer.MailError as exc: + raise HTTPException(400, f"invalid_address:{exc.detail}") + log = mailer.send(db, admin, cfg, to=to, cc=[], kind="test", context="test", + subject="Budget & Capex — test", attachments=[], + body="Mensagem de teste enviada pela aplicação Orçamento & Capex.\n" + "Test message sent by the Budget & Capex app.") + return log_out(log) + + +@router.get("/mail/status") +def mail_status(db: Session = Depends(get_db), _: User = Depends(current_user)): + cfg = db.get(MailSettings, 1) + return {"enabled": bool(cfg and mailer.is_ready(cfg)), "from": cfg.from_address if cfg else "", + "signature": cfg.signature if cfg else ""} + + +class ReportRef(BaseModel): + kind: str + fmt: str + + +class EmailIn(BaseModel): + to: list[str] = Field(default_factory=list) + cc: list[str] = Field(default_factory=list) + subject: str = Field(default="", max_length=300) + message: str = Field(default="", max_length=10000) + reports: list[ReportRef] = Field(default_factory=list) + lang: str | None = None + site_id: int | None = None + include_empty: bool = False + copy_me: bool = False + + +@router.post("/years/{year_id}/email") +def email_reports(year_id: int, body: EmailIn, db: Session = Depends(get_db), user: User = Depends(current_user)): + cfg = db.get(MailSettings, 1) + if not cfg or not mailer.is_ready(cfg): + raise HTTPException(409, "mail_not_configured") + year = get_year(db, year_id) + try: + to = mailer.clean_addresses(body.to) + cc = mailer.clean_addresses(body.cc + ([user.email] if body.copy_me else [])) + except mailer.MailError as exc: + raise HTTPException(400, f"invalid_address:{exc.detail}") + cc = [a for a in cc if a.lower() not in {t.lower() for t in to}] + if not to: + raise HTTPException(400, "no_recipients") + if len(to) + len(cc) > mailer.MAX_RECIPIENTS: + raise HTTPException(400, "too_many_recipients") + refs = {(r.kind, r.fmt) for r in body.reports} + if not refs or any(not ((k in KINDS and f in FORMATS and f != "html") or (k, f) == ("workbook", "xlsx")) + for k, f in refs): + raise HTTPException(400, "no_attachments") + lang = report_lang(body.lang, user) + attachments = [] + for kind, fmt in sorted(refs): + if kind == "workbook": # the editable budget file + data, name = workbook_file(db, year, lang) + else: + data, name = render_report(db, year, kind, fmt, lang, body.site_id, body.include_empty) + attachments.append(mailer.Attachment(name, MIME[fmt], data)) + if sum(len(a.data) for a in attachments) > MAX_ATTACHMENTS_BYTES: + raise HTTPException(400, "file_too_large") + subject = body.subject.strip() or f"{year.department.name} {year.year} — {year.title}" + log = mailer.send(db, user, cfg, to=to, cc=cc, subject=subject, body=body.message, attachments=attachments, + year_id=year.id, context=f"{year.department.name} {year.year}") + if log.status != "sent": + raise HTTPException(502, log.error.split(":")[0] or "smtp_error") + return log_out(log) + + +def log_out(m: MailLog) -> dict: + return {"id": m.id, "at": m.at.isoformat(), "user": m.user.name if m.user else None, + "user_email": m.user.email if m.user else None, "kind": m.kind, "year_id": m.year_id, + "context": m.context, "sender": m.sender, "to": (m.recipients or {}).get("to", []), + "cc": (m.recipients or {}).get("cc", []), "subject": m.subject, "body": m.body, + "attachments": m.attachments or [], "status": m.status, "error": m.error, + "sent_copy": m.sent_copy, "sent_copy_detail": m.sent_copy_detail, "message_id": m.message_id} + + +def _log_query(q: str | None, status: str | None, user_id: int | None, date_from: str | None, date_to: str | None): + query = select(MailLog) + if status: + query = query.where(MailLog.status == status) + if user_id: + query = query.where(MailLog.user_id == user_id) + if date_from: + query = query.where(MailLog.at >= _date(date_from)) + if date_to: + query = query.where(MailLog.at < _date(date_to, end=True)) + if q: + like = f"%{q.strip().lower()}%" + query = query.where(or_(func.lower(MailLog.subject).like(like), func.lower(MailLog.context).like(like), + func.lower(cast(MailLog.recipients, String)).like(like))) + return query + + +def _date(text: str, end: bool = False) -> datetime: + try: + d = datetime.fromisoformat(text[:10]).replace(tzinfo=timezone.utc) + except ValueError: + raise HTTPException(400, "invalid_date") + return d + timedelta(days=1) if end else d + + +@router.get("/mail/log") +def mail_log(q: str | None = None, status: str | None = None, user_id: int | None = None, + date_from: str | None = None, date_to: str | None = None, limit: int = 100, offset: int = 0, + db: Session = Depends(get_db), _: User = Depends(require("admin"))): + query = _log_query(q, status, user_id, date_from, date_to) + total = db.scalar(select(func.count()).select_from(query.subquery())) + rows = db.scalars(query.order_by(MailLog.at.desc(), MailLog.id.desc()) + .limit(min(max(limit, 1), 500)).offset(max(offset, 0))).all() + return {"total": total, "rows": [log_out(m) for m in rows]} + + +@router.get("/mail/log.csv") +def mail_log_csv(q: str | None = None, status: str | None = None, user_id: int | None = None, + date_from: str | None = None, date_to: str | None = None, + db: Session = Depends(get_db), _: User = Depends(require("admin"))): + rows = db.scalars(_log_query(q, status, user_id, date_from, date_to).order_by(MailLog.at.desc())).all() + out = io.StringIO() + w = csv.writer(out, delimiter=";") + w.writerow(["date", "user", "kind", "budget", "from", "to", "cc", "subject", "attachments", "status", "error", + "sent_copy", "sent_copy_detail"]) + for m in rows: + o = log_out(m) + w.writerow([o["at"], o["user"], o["kind"], o["context"], o["sender"], ", ".join(o["to"]), ", ".join(o["cc"]), + o["subject"], ", ".join(a["name"] for a in o["attachments"]), o["status"], o["error"], + o["sent_copy"], o["sent_copy_detail"]]) + return Response("" + out.getvalue(), media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": 'attachment; filename="mail_log.csv"'}) diff --git a/budget-app/backend/app/routers/reports.py b/budget-app/backend/app/routers/reports.py new file mode 100644 index 0000000..75a8626 --- /dev/null +++ b/budget-app/backend/app/routers/reports.py @@ -0,0 +1,91 @@ +import re + +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import HTMLResponse, Response +from sqlalchemy.orm import Session + +from ..audit import record +from ..config import settings +from ..db import get_db +from ..models import BudgetYear, Site, User +from ..reports import excel, render +from ..security import current_user +from ..summary import build_year_view +from ..template import build_template +from .years import get_year + +router = APIRouter(prefix="/api", tags=["reports"]) + +XLSX = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +KINDS = ("cost-report", "budget") +FORMATS = ("html", "pdf", "xlsx") +MIME = {"html": "text/html; charset=utf-8", "pdf": "application/pdf", "xlsx": XLSX} + + +def _filename(kind: str, year: int, dept: str, site: str | None, ext: str) -> str: + stem = {"cost-report": "Capex_e_Custos", "budget": "Budget"}[kind] + clean = lambda text: re.sub(r"[^A-Za-z0-9]+", "_", text).strip("_") + suffix = f"_{clean(site)}" if site else "" + return f"{stem}_{clean(dept)}_{year}{suffix}.{ext}" + + +def disposition(name: str) -> str: + """Content-Disposition for any file name (accents included).""" + from urllib.parse import quote + ascii_name = name.encode("ascii", "replace").decode().replace("?", "_").replace('"', "") + return f"attachment; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(name)}" + + +def report_lang(lang: str | None, user: User) -> str: + return lang if lang in ("pt", "en") else (user.language or settings.default_language) + + +def render_report(db: Session, year: BudgetYear, kind: str, fmt: str, lang: str, site_id: int | None = None, + include_empty: bool = False, screen: bool = False) -> tuple[bytes, str]: + """The report file and its download name. Shared by the download links and by email.""" + site = db.get(Site, site_id).name if site_id and db.get(Site, site_id) else None + view = build_year_view(db, year, site_id if site else None) + name = _filename(kind, year.year, year.department.name, site, fmt) + if fmt == "xlsx": + data = (excel.cost_report_xlsx(view, lang, site) if kind == "cost-report" + else excel.budget_xlsx(view, lang, site, include_empty=True)) + return data, name + html = (render.cost_report_html(view, lang, site, screen=screen) if kind == "cost-report" + else render.budget_html(view, lang, site, include_empty)) + if fmt == "html": + return html.encode(), name + return render.to_pdf(html), name + + +@router.get("/years/{year_id}/reports/{kind}.{fmt}") +def report(year_id: int, kind: str, fmt: str, lang: str | None = None, site_id: int | None = None, + include_empty: bool = False, db: Session = Depends(get_db), user: User = Depends(current_user)): + if kind not in KINDS or fmt not in FORMATS: + raise HTTPException(404, "not_found") + year = get_year(db, year_id) + data, name = render_report(db, year, kind, fmt, report_lang(lang, user), site_id, include_empty, + screen=(fmt == "html")) + record(db, user, "report", year.id, "view" if fmt == "html" else "download", + f"{year.department.name} {year.year} · {kind}.{fmt}", year_id=year.id) + db.commit() + if fmt == "html": + return HTMLResponse(data.decode()) + return Response(data, media_type=MIME[fmt], headers={"Content-Disposition": f'attachment; filename="{name}"'}) + + +def workbook_file(db: Session, year: BudgetYear, lang: str) -> tuple[bytes, str]: + lines = [lv for cv in build_year_view(db, year).categories for lv in cv.lines] + data = build_template(db, year.department, lang, year.year, year.default_rate or 0, lines) + clean = re.sub(r"[^A-Za-z0-9]+", "_", year.department.name).strip("_") + return data, year.source_file or f"{'Orcamento' if lang == 'pt' else 'Budget'}_{clean}_{year.year}.xlsx" + + +@router.get("/years/{year_id}/workbook.xlsx") +def budget_workbook(year_id: int, lang: str | None = None, db: Session = Depends(get_db), + user: User = Depends(current_user)): + """The budget as an editable Excel template, ready to be changed and uploaded again.""" + year = get_year(db, year_id) + data, name = workbook_file(db, year, report_lang(lang, user)) + record(db, user, "year", year.id, "download", f"{year.department.name} {year.year} · {name}", year_id=year.id) + db.commit() + return Response(data, media_type=XLSX, headers={"Content-Disposition": disposition(name)}) diff --git a/budget-app/backend/app/routers/years.py b/budget-app/backend/app/routers/years.py new file mode 100644 index 0000000..6afb4da --- /dev/null +++ b/budget-app/backend/app/routers/years.py @@ -0,0 +1,460 @@ +import os +from datetime import datetime, timezone +from zoneinfo import ZoneInfo +from decimal import Decimal + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ..audit import diff, record, snapshot +from ..calc import ExpressionError, clean_months, evaluate, line_figures +from ..db import get_db +from .. import notify +from ..importer import ImportErrorWithDetail, archived_name, current_year, import_workbook +from ..models import AuditEntry, BudgetYear, Category, CostLine, Department, ReopenRequest, Site, User +from ..permissions import (allowed_actions, is_management, can_reopen, current_step, pending_reopen, require_action, + require_department_editor, steps_of) +from ..security import current_user +from ..serializers import reopen_out, year_detail, year_out +from ..summary import build_year_view + +router = APIRouter(prefix="/api", tags=["years"]) + +LINE_FIELDS = ["category_id", "site_id", "name", "description", "remarks", "prior_expr", "base_expr", + "adj_kind", "adj_value", "adj_reason", "monthly_kind", "monthly_values", "monthly_months", "sort_order"] + + +def get_year(db: Session, year_id: int) -> BudgetYear: + y = db.get(BudgetYear, year_id) + if not y: + raise HTTPException(404, "not_found") + return y + + +def get_department(db: Session, department_id: int | None) -> Department: + d = db.get(Department, department_id or 0) + if not d: + raise HTTPException(400, "invalid_department") + return d + + +@router.get("/years") +def list_years(department_id: int | None = None, archived: bool = False, db: Session = Depends(get_db), + user: User = Depends(current_user)): + """Current budgets, or with archived=true the versions that were replaced by an upload.""" + q = (select(BudgetYear).where(BudgetYear.archived.is_(archived)) + .order_by(BudgetYear.year.desc(), BudgetYear.department_id, BudgetYear.archived_at.desc())) + if department_id: + q = q.where(BudgetYear.department_id == department_id) + return [year_out(y, build_year_view(db, y), user) for y in db.scalars(q)] + + +class YearIn(BaseModel): + department_id: int | None = None + year: int | None = Field(default=None, ge=2000, le=2100) + title: str | None = None + default_rate: float | None = Field(default=None, ge=-1, le=10) + notes: str | None = None + copy_from_id: int | None = None + + +@router.post("/years") +def create_year(body: YearIn, db: Session = Depends(get_db), user: User = Depends(current_user)): + dept = get_department(db, body.department_id or user.department_id) + require_department_editor(user, dept.id) + if not body.year: + raise HTTPException(400, "missing_fields") + if current_year(db, dept.id, body.year): + raise HTTPException(400, "year_exists") + src = get_year(db, body.copy_from_id) if body.copy_from_id else None + if src and src.department_id != dept.id: + raise HTTPException(400, "invalid_department") + rate = Decimal(str(body.default_rate)) if body.default_rate is not None else (src.default_rate if src else Decimal(0)) + y = BudgetYear(department_id=dept.id, year=body.year, title=body.title or f"Budget {body.year}", + default_rate=rate, notes=body.notes or "") + db.add(y) + db.flush() + if src: + # Last year's adjusted budget becomes this year's starting point for both actual and base; + # users then replace the actual with the real figure. + for line in src.lines: + f = line_figures(line, src.default_rate) + start = str(f.adjusted) + db.add(CostLine( + year_id=y.id, category_id=line.category_id, site_id=line.site_id, name=line.name, + description=line.description, remarks="", prior_expr=start, prior_actual=f.adjusted, + base_expr=start, base_amount=f.adjusted, adj_kind=line.adj_kind, adj_value=line.adj_value, + adj_reason=line.adj_reason, monthly_kind=line.monthly_kind, monthly_values=line.monthly_values, + monthly_months=line.monthly_months, + sort_order=line.sort_order)) + record(db, user, "year", y.id, "create", f"{dept.name} {y.year}" + (f" from {src.year}" if src else ""), + year_id=y.id) + db.commit() + return year_out(y) + + +@router.post("/years/import") +async def import_year(file: UploadFile = File(...), year: int | None = Form(None), replace: bool = Form(False), + department_id: int | None = Form(None), + db: Session = Depends(get_db), user: User = Depends(current_user)): + dept = get_department(db, department_id or user.department_id) + require_department_editor(user, dept.id) + data = await file.read() + if len(data) > 20 * 1024 * 1024: + raise HTTPException(400, "file_too_large") + try: + result = import_workbook(db, data, user, dept.id, year=year, replace=replace, + filename=_clean_filename(file.filename)) + except ImportErrorWithDetail as exc: + db.rollback() + raise HTTPException(409 if str(exc) in ("year_exists", "year_not_draft") else 400, str(exc)) + y = current_year(db, dept.id, result.year) + archived = db.scalars(select(BudgetYear).where(BudgetYear.department_id == dept.id, BudgetYear.year == result.year, + BudgetYear.archived.is_(True)) + .order_by(BudgetYear.archived_at.desc())).first() if replace else None + return {"archived": year_out(archived) if archived else None,"year": year_out(y), "lines": result.lines, "categories_created": result.categories_created, + "warnings": result.warnings} + + +def _clean_filename(name: str | None) -> str: + return (name or "").replace("\\", "/").rsplit("/", 1)[-1].strip()[:255] + + +@router.post("/years/import/check") +async def check_import(file: UploadFile = File(...), year: int | None = Form(None), + department_id: int | None = Form(None), + db: Session = Depends(get_db), user: User = Depends(current_user)): + """Which budget an upload would create, and whether it would replace (and archive) a current one.""" + dept = get_department(db, department_id or user.department_id) + require_department_editor(user, dept.id) + data = await file.read() + if len(data) > 20 * 1024 * 1024: + raise HTTPException(400, "file_too_large") + try: + result = import_workbook(db, data, user, dept.id, year=year, check_only=True) + except ImportErrorWithDetail as exc: + raise HTTPException(400, str(exc)) + existing = current_year(db, dept.id, result.year) + name = _clean_filename(file.filename) + out = {"year": result.year, "department": dept.name, "filename": name, "existing": None} + if existing: + now = datetime.now(ZoneInfo(os.getenv("TZ") or "Europe/Lisbon")) + out["existing"] = { + **year_out(existing, build_year_view(db, existing)), "can_replace": existing.status == "draft", + "same_file": bool(existing.source_file) and existing.source_file.lower() == name.lower(), + "archived_title": archived_name(existing.title or f"Budget {existing.year}", now), + "archived_file": archived_name(existing.source_file, now) if existing.source_file else "", + } + return out + + +@router.get("/years/{year_id}") +def read_year(year_id: int, site_id: int | None = None, db: Session = Depends(get_db), + user: User = Depends(current_user)): + return year_detail(build_year_view(db, get_year(db, year_id), site_id), user) + + +@router.put("/years/{year_id}") +def update_year(year_id: int, body: YearIn, db: Session = Depends(get_db), user: User = Depends(current_user)): + y = get_year(db, year_id) + require_action(user, y, "edit") + before = snapshot(y, ["title", "default_rate", "notes"]) + if body.title is not None: + y.title = body.title + if body.default_rate is not None: + y.default_rate = Decimal(str(body.default_rate)) + if body.notes is not None: + y.notes = body.notes + record(db, user, "year", y.id, "update", str(y.year), diff(before, snapshot(y, ["title", "default_rate", "notes"])), + year_id=y.id) + db.commit() + return year_out(y, user=user) + + +@router.delete("/years/{year_id}") +def delete_year(year_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): + y = get_year(db, year_id) + require_action(user, y, "delete") + record(db, user, "year", y.id, "delete", + f"{y.department.name} {y.year} · {y.title} ({y.status}, {len(y.lines)} lines)", year_id=y.id) + db.delete(y) + db.commit() + return {"ok": True} + + +class StatusIn(BaseModel): + action: str # submit | approve | return | reopen + comment: str | None = Field(default=None, max_length=500) + + +@router.post("/years/{year_id}/status") +def change_status(year_id: int, body: StatusIn, db: Session = Depends(get_db), user: User = Depends(current_user)): + y = get_year(db, year_id) + if body.action not in ("submit", "approve", "return", "reopen"): + raise HTTPException(400, "invalid_action") + expected = {"submit": "draft", "approve": "in_review", "return": "in_review", "reopen": "approved"}[body.action] + if y.status != expected: + raise HTTPException(409, "invalid_transition") + if not allowed_actions(user, y)[body.action]: + raise HTTPException(403, "forbidden") + if body.action == "reopen" and not (body.comment or "").strip(): + raise HTTPException(400, "reason_required") + now = datetime.now(timezone.utc) + before = y.status + steps = steps_of(y.department) + step = current_step(y) + summary = body.comment or "" + if body.action == "submit": + y.status, y.current_step = "in_review", 0 + y.submitted_by_id, y.submitted_at = user.id, now + y.approved_by_id = y.approved_at = None + elif body.action == "approve": + index = y.current_step or 0 + summary = (f"{step.name}: " if step else "") + summary + if index + 1 < len(steps): + y.current_step = index + 1 + else: + y.status, y.current_step = "approved", None + y.approved_by_id, y.approved_at = user.id, now + else: # return / reopen + y.status, y.current_step = "draft", None + y.approved_by_id = y.approved_at = None + if body.action == "reopen": + _reopen(y, user, now) + record(db, user, "year", y.id, body.action, summary.strip(" :"), + {"status": [before, y.status], **({"step": step.name} if step else {})}, year_id=y.id) + db.commit() + return year_out(y, user=user) + + +def _reopen(y: BudgetYear, user: User, now: datetime) -> None: + y.status, y.current_step = "draft", None + y.approved_by_id = y.approved_at = None + pending = pending_reopen(y) + if pending: # reopening answers an open request + pending.status, pending.decided_by_id, pending.decided_at = "accepted", user.id, now + + +class ReopenIn(BaseModel): + reason: str = Field(default="", max_length=2000) + + +@router.post("/years/{year_id}/reopen-requests") +def request_reopen(year_id: int, body: ReopenIn, db: Session = Depends(get_db), user: User = Depends(current_user)): + """Anyone may ask for an approved budget to be reopened, saying why. Approvers reopen it at once.""" + y = get_year(db, year_id) + if is_management(user): + raise HTTPException(403, "forbidden") + reason = body.reason.strip() + if len(reason) < 5: + raise HTTPException(400, "reason_required") + if y.archived or y.status != "approved": + raise HTTPException(409, "invalid_transition") + if pending_reopen(y) and not can_reopen(user, y): + raise HTTPException(409, "reopen_already_requested") + now = datetime.now(timezone.utc) + req = ReopenRequest(year_id=y.id, requested_by_id=user.id, requested_at=now, reason=reason, status="pending", + decision_note="") + label = f"{y.department.name} {y.year}" + if can_reopen(user, y): + pending = pending_reopen(y) + if pending: # an approver reopening answers the open request + req = pending + req.decision_note = reason + else: + db.add(req) + db.flush() + db.refresh(y) + _reopen(y, user, now) + record(db, user, "year", y.id, "reopen", reason, {"status": ["approved", "draft"]}, year_id=y.id) + db.commit() + return {"reopened": True, "request": reopen_out(req), "year": year_out(y, user=user)} + db.add(req) + db.flush() + db.refresh(y) + record(db, user, "year", y.id, "reopen_requested", reason, year_id=y.id) + db.commit() + notify.send(db, user, y, notify.reopeners(db, y), f"Pedido de reabertura / Reopen request: {label}", + f"{user.name} pediu a reabertura do orçamento {label} ({y.title}).\n" + f"{user.name} asked for the {label} budget ({y.title}) to be reopened.\n\n" + f"Motivo / Reason:\n{reason}\n") + return {"reopened": False, "request": reopen_out(req), "year": year_out(y, user=user)} + + +class DecisionIn(BaseModel): + accept: bool + note: str = Field(default="", max_length=2000) + + +@router.post("/reopen-requests/{request_id}/decide") +def decide_reopen(request_id: int, body: DecisionIn, db: Session = Depends(get_db), user: User = Depends(current_user)): + req = db.get(ReopenRequest, request_id) + if not req: + raise HTTPException(404, "not_found") + y = req.budget_year + if req.status != "pending" or y.status != "approved": + raise HTTPException(409, "invalid_transition") + if not can_reopen(user, y) or y.archived: + raise HTTPException(403, "forbidden") + if not body.accept and not body.note.strip(): + raise HTTPException(400, "reason_required") + now = datetime.now(timezone.utc) + note = body.note.strip() + label = f"{y.department.name} {y.year}" + if body.accept: + _reopen(y, user, now) + req.decision_note = note + record(db, user, "year", y.id, "reopen", f"{req.reason}" + (f" — {note}" if note else ""), + {"status": ["approved", "draft"], "requested_by": req.requested_by.name if req.requested_by else None}, + year_id=y.id) + else: + req.status, req.decided_by_id, req.decided_at, req.decision_note = "declined", user.id, now, note + record(db, user, "year", y.id, "reopen_declined", note, year_id=y.id) + db.commit() + if req.requested_by: + verdict = ("aceite / accepted: o orçamento voltou a rascunho / the budget is back in draft" if body.accept + else "recusado / declined") + notify.send(db, user, y, [req.requested_by], f"Pedido de reabertura {label}: {verdict.split(':')[0]}", + f"O seu pedido de reabertura do orçamento {label} foi {verdict}.\n" + f"Your request to reopen the {label} budget was {verdict}.\n\n" + f"{user.name}" + (f":\n{note}\n" if note else "\n")) + return {"request": reopen_out(req), "year": year_out(y, user=user)} + + +@router.get("/years/{year_id}/audit") +def year_audit(year_id: int, limit: int = 200, db: Session = Depends(get_db), _: User = Depends(current_user)): + get_year(db, year_id) + rows = db.scalars(select(AuditEntry).where(AuditEntry.year_id == year_id) + .order_by(AuditEntry.at.desc()).limit(min(limit, 1000))).all() + return [{"at": a.at.isoformat(), "user": a.user.name if a.user else None, "entity": a.entity, + "entity_id": a.entity_id, "action": a.action, "summary": a.summary, "changes": a.changes} + for a in rows] + + +class LineIn(BaseModel): + category_id: int | None = None + site_id: int | None = None + name: str | None = Field(default=None, max_length=255) + description: str | None = Field(default=None, max_length=500) + remarks: str | None = None + prior_expr: str | None = Field(default=None, max_length=400) + base_expr: str | None = Field(default=None, max_length=400) + adj_kind: str | None = None + adj_value: float | None = None + adj_reason: str | None = Field(default=None, max_length=255) + monthly_kind: str | None = None + monthly_values: list[float] | None = None + monthly_months: list[int] | None = None + sort_order: int | None = None + + +def _apply_line(line: CostLine, body: LineIn, db: Session, department_id: int) -> None: + data = body.model_dump(exclude_unset=True) + if "category_id" in data: + cat = db.get(Category, data["category_id"] or 0) + if not cat or cat.department_id != department_id: + raise HTTPException(400, "invalid_category") + line.category_id = data["category_id"] + if "site_id" in data: + if data["site_id"] is not None and not db.get(Site, data["site_id"]): + raise HTTPException(400, "invalid_site") + line.site_id = data["site_id"] + for f in ("name", "description", "remarks", "adj_reason", "sort_order"): + if f in data and data[f] is not None: + setattr(line, f, data[f].strip() if isinstance(data[f], str) else data[f]) + for f, target in (("prior_expr", "prior_actual"), ("base_expr", "base_amount")): + if f in data and data[f] is not None: + try: + value = evaluate(data[f]) + except ExpressionError: + raise HTTPException(400, f"invalid_expression:{f}") + setattr(line, f, data[f].strip().lstrip("=") or "0") + setattr(line, target, value) + if "adj_kind" in data: + if data["adj_kind"] not in ("year_rate", "percent", "factor", "none"): + raise HTTPException(400, "invalid_adjustment") + line.adj_kind = data["adj_kind"] + if data.get("adj_value") is not None: + line.adj_value = Decimal(str(data["adj_value"])) + if "monthly_kind" in data: + if data["monthly_kind"] not in ("even", "selected", "manual"): + raise HTTPException(400, "invalid_monthly") + if data["monthly_kind"] == "manual" and line.monthly_kind != "manual" and not data.get("monthly_values"): + # Switching to typed months starts from the current even spread. + year = db.get(BudgetYear, line.year_id) + spread = line_figures(line, year.default_rate if year else Decimal(0)).months + line.monthly_values = [float(m.quantize(Decimal("0.01"))) for m in spread] + if line.monthly_kind == "manual" and data["monthly_kind"] != "manual" and data.get("base_expr") is None: + # Leaving typed months keeps the year total: it becomes the base, without a further adjustment. + total = sum((Decimal(str(v or 0)) for v in (line.monthly_values or [])), Decimal(0)) + line.base_expr, line.base_amount = str(total), total + if data.get("adj_kind") is None: + line.adj_kind, line.adj_value = "none", Decimal(0) + line.monthly_kind = data["monthly_kind"] + if "monthly_values" in data: + values = data["monthly_values"] + if values is not None and len(values) != 12: + raise HTTPException(400, "invalid_monthly") + line.monthly_values = values + if line.monthly_kind == "manual" and not line.monthly_values: + line.monthly_values = [0.0] * 12 + if "monthly_months" in data: + values = data["monthly_months"] + if values is not None and any(not 1 <= m <= 12 for m in values): + raise HTTPException(400, "invalid_monthly") + line.monthly_months = clean_months(values) or None + if line.monthly_kind == "selected" and not line.monthly_months: + raise HTTPException(400, "no_months_selected") + if not line.name: + raise HTTPException(400, "missing_fields") + + +@router.post("/years/{year_id}/lines") +def create_line(year_id: int, body: LineIn, db: Session = Depends(get_db), user: User = Depends(current_user)): + y = get_year(db, year_id) + require_action(user, y, "edit") + if not body.category_id or not body.name: + raise HTTPException(400, "missing_fields") + line = CostLine(year_id=y.id, name="", description="", remarks="", prior_expr="0", prior_actual=Decimal(0), + base_expr="0", base_amount=Decimal(0), adj_kind="year_rate", adj_value=Decimal(0), + adj_reason="", monthly_kind="even", sort_order=0) + _apply_line(line, body, db, y.department_id) + if not line.sort_order: + line.sort_order = max([l.sort_order for l in y.lines if l.category_id == line.category_id] or [0]) + 10 + db.add(line) + db.flush() + record(db, user, "line", line.id, "create", line.name, snapshot(line, LINE_FIELDS), year_id=y.id) + db.commit() + return {"id": line.id} + + +def _line_and_year(db: Session, line_id: int) -> tuple[CostLine, BudgetYear]: + line = db.get(CostLine, line_id) + if not line: + raise HTTPException(404, "not_found") + return line, line.budget_year + + +@router.put("/lines/{line_id}") +def update_line(line_id: int, body: LineIn, db: Session = Depends(get_db), user: User = Depends(current_user)): + line, y = _line_and_year(db, line_id) + require_action(user, y, "edit") + before = snapshot(line, LINE_FIELDS) + _apply_line(line, body, db, y.department_id) + changes = diff(before, snapshot(line, LINE_FIELDS)) + if changes: + record(db, user, "line", line.id, "update", line.name, changes, year_id=y.id) + db.commit() + return {"id": line.id} + + +@router.delete("/lines/{line_id}") +def delete_line(line_id: int, db: Session = Depends(get_db), user: User = Depends(current_user)): + line, y = _line_and_year(db, line_id) + require_action(user, y, "edit") + record(db, user, "line", line.id, "delete", line.name, snapshot(line, LINE_FIELDS), year_id=y.id) + db.delete(line) + db.commit() + return {"ok": True} diff --git a/budget-app/backend/app/security.py b/budget-app/backend/app/security.py new file mode 100644 index 0000000..58b3968 --- /dev/null +++ b/budget-app/backend/app/security.py @@ -0,0 +1,54 @@ +from datetime import datetime, timedelta, timezone + +import bcrypt +import jwt +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from .config import settings +from .db import get_db +from .models import User + +COOKIE = "budget_session" +# Ordered by what each role may change. "management" (Upper Management) sits below editor: it only +# views, downloads and emails budgets and reports, in every department. +ROLES = ("viewer", "management", "editor", "approver", "admin") + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def verify_password(password: str, hashed: str) -> bool: + try: + return bcrypt.checkpw(password.encode(), hashed.encode()) + except ValueError: + return False + + +def make_token(user: User) -> str: + exp = datetime.now(timezone.utc) + timedelta(hours=settings.session_hours) + return jwt.encode({"sub": str(user.id), "exp": exp}, settings.secret_key, algorithm="HS256") + + +def current_user(request: Request, db: Session = Depends(get_db)) -> User: + token = request.cookies.get(COOKIE) + if not token: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated") + try: + payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"]) + except jwt.PyJWTError: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated") + user = db.get(User, int(payload["sub"])) + if not user or not user.active: + raise HTTPException(status.HTTP_401_UNAUTHORIZED, "not_authenticated") + return user + + +def require(min_role: str): + def dep(user: User = Depends(current_user)) -> User: + if ROLES.index(user.role) < ROLES.index(min_role): + raise HTTPException(status.HTTP_403_FORBIDDEN, "forbidden") + return user + + return dep diff --git a/budget-app/backend/app/seed.py b/budget-app/backend/app/seed.py new file mode 100644 index 0000000..5c402b6 --- /dev/null +++ b/budget-app/backend/app/seed.py @@ -0,0 +1,56 @@ +"""Default chart of accounts, taken from the 'Budget IT' sheet (same order).""" + +CATEGORIES = [ + ("Revenue", "Receitas", "revenue"), + ("Cost of Cell Phones", "Custos com telemóveis", "cost"), + ("Cost of Internet Services", "Serviços de internet", "cost"), + ("Cost of Local Calls", "Chamadas locais", "cost"), + ("Cost of Long Distance Calls", "Chamadas internacionais", "cost"), + ("Other Cost of Services", "Outros custos de serviços", "cost"), + ("Administrative and General", "Administrativos e gerais", "cost"), + ("Centralized Information System Charges", "Encargos de sistemas de informação centralizados", "cost"), + ("Energy Management", "Gestão de energia", "cost"), + ("Food and Beverage", "Comidas e bebidas", "cost"), + ("Golf", "Golfe", "cost"), + ("Hardware", "Hardware", "cost"), + ("Health Club/Spa", "Health club/Spa", "cost"), + ("Human Resources", "Recursos humanos", "cost"), + ("Information Security", "Segurança da informação", "cost"), + ("Information Systems", "Sistemas de informação", "cost"), + ("Other", "Outros", "cost"), + ("Parking", "Estacionamento", "cost"), + ("Property Operation and Maintenance", "Operação e manutenção do edifício", "cost"), + ("Rooms", "Quartos", "cost"), + ("Sales and Marketing", "Vendas e marketing", "cost"), + ("Telecommunications", "Telecomunicações", "cost"), + ("Other IT Equipment", "Outro equipamento informático", "cost"), + ("System Storage and Optimization", "Armazenamento e otimização de sistemas", "cost"), + ("Complimentary Services and Gifts", "Serviços de cortesia e ofertas", "cost"), + ("Contract Services", "Serviços contratados", "cost"), + ("Corporate Office Reimbursables", "Reembolsos à sede", "cost"), + ("Dues and Subscriptions", "Quotas e subscrições", "cost"), + ("Entertainment-In-House", "Entretenimento interno", "cost"), + ("Equipment Rental", "Aluguer de equipamento", "cost"), + ("Miscellaneous", "Diversos", "cost"), + ("Cleaning Supplies", "Material de limpeza", "cost"), + ("Food Supplies", "Abastecimento alimentar", "cost"), + ("Give Aways Supplies", "Brindes", "cost"), + ("Guest Supplies", "Amenidades para hóspedes", "cost"), + ("Laundry Supplies", "Material de lavandaria", "cost"), + ("Office Supplies", "Material de escritório", "cost"), + ("Operating Supplies", "Material operacional", "cost"), + ("Other Beverage Supplies", "Outras bebidas", "cost"), + ("Other Supplies", "Outros materiais", "cost"), + ("Paper and Plastics Disposables", "Descartáveis de papel e plástico", "cost"), + ("Spa Supplies", "Material de spa", "cost"), + ("Tobacco Supplies", "Tabaco", "cost"), + ("Wine Supplies", "Vinhos", "cost"), + ("Training", "Formação", "cost"), + ("Travel-Meals and Entertainment", "Viagens - refeições e entretenimento", "cost"), + ("Travel-Other", "Viagens - outros", "cost"), + ("Uniform Costs", "Fardamentos", "cost"), + ("Uniform Laundry", "Lavandaria de fardamentos", "cost"), + ("Internal Recharge", "Imputações internas", "cost"), +] + +SITES = ["Sagres", "Quinta", "Chiado", "Oriente"] diff --git a/budget-app/backend/app/serializers.py b/budget-app/backend/app/serializers.py new file mode 100644 index 0000000..54c13e1 --- /dev/null +++ b/budget-app/backend/app/serializers.py @@ -0,0 +1,103 @@ +from decimal import Decimal + +from .calc import round2 +from .models import BudgetYear, Category, Department, Site, User, WorkflowStep +from .permissions import allowed_actions, steps_of +from .summary import LineView, YearView + + +def money(value: Decimal | None) -> float | None: + return None if value is None else float(round2(value)) + + +def pct(value: Decimal | None) -> float | None: + return None if value is None else float(Decimal(value).quantize(Decimal("0.01"))) + + +def user_out(u: User) -> dict: + return {"id": u.id, "email": u.email, "name": u.name, "role": u.role, "language": u.language, + "active": u.active, "department_id": u.department_id, + "department": u.department.name if u.department else None, "theme": u.theme or "system", + "mfa_enabled": bool(u.mfa_enabled), "mfa_enabled_at": u.mfa_enabled_at.isoformat() if u.mfa_enabled_at else None, + "mfa_recovery_left": len(u.mfa_recovery or []) if u.mfa_enabled else 0, + # the timestamp changes the picture's URL, so browsers fetch a new one after a change + "avatar_url": f"/api/users/{u.id}/avatar?v={int(u.avatar_updated_at.timestamp())}" if u.avatar_updated_at else None} + + +def step_out(s: WorkflowStep) -> dict: + return {"id": s.id, "position": s.position, "name": s.name, "approver_mode": s.approver_mode, + "approver_ids": s.approver_ids or []} + + +def department_out(d: Department) -> dict: + return {"id": d.id, "name": d.name, "active": d.active, "steps": [step_out(s) for s in steps_of(d)]} + + +def site_out(s: Site) -> dict: + return {"id": s.id, "name": s.name, "active": s.active} + + +def category_out(c: Category) -> dict: + return {"id": c.id, "department_id": c.department_id, "name_en": c.name_en, "name_pt": c.name_pt, "section": c.section, + "sort_order": c.sort_order, "show_when_empty": c.show_when_empty, "active": c.active} + + +def totals_out(t) -> dict: + return {"prior": money(t.prior), "base": money(t.base), "adjusted": money(t.adjusted), + "change": money(t.change), "change_pct": pct(t.change_pct), "months": [money(m) for m in t.months]} + + +def line_out(lv: LineView) -> dict: + line, f = lv.line, lv.figures + return { + "id": line.id, "category_id": line.category_id, "site_id": line.site_id, "name": line.name, + "description": line.description, "remarks": line.remarks, + "prior_expr": line.prior_expr, "base_expr": line.base_expr, + "adj_kind": line.adj_kind, "adj_value": float(line.adj_value or 0), "adj_reason": line.adj_reason, + "monthly_kind": line.monthly_kind, "monthly_values": line.monthly_values, + "monthly_months": line.monthly_months, + "sort_order": line.sort_order, + "prior": money(f.prior), "base": money(f.base), "factor": float(f.factor), "adjusted": money(f.adjusted), + "change": money(f.change), "change_pct": pct(f.change_pct), "months": [money(m) for m in f.months], + } + + +def year_out(y: BudgetYear, view: YearView | None = None, user: User | None = None) -> dict: + out = { + "id": y.id, "year": y.year, "title": y.title, "default_rate": float(y.default_rate or 0), + "department_id": y.department_id, "department": y.department.name, + "status": y.status, "notes": y.notes, "current_step": y.current_step, + "workflow": [step_out(s) for s in steps_of(y.department)], + "submitted_by": y.submitted_by.name if y.submitted_by else None, + "submitted_at": y.submitted_at.isoformat() if y.submitted_at else None, + "approved_by": y.approved_by.name if y.approved_by else None, + "approved_at": y.approved_at.isoformat() if y.approved_at else None, + "source_file": y.source_file or "", "archived": bool(y.archived), + "archived_at": y.archived_at.isoformat() if y.archived_at else None, + "archived_by": y.archived_by.name if y.archived_by else None, + "reopen_request": next((reopen_out(r) for r in y.reopen_requests if r.status == "pending"), None), + } + if user is not None: + out["actions"] = allowed_actions(user, y) + if view is not None: + out["costs"] = totals_out(view.costs) + out["revenue"] = totals_out(view.revenue) + return out + + +def reopen_out(r) -> dict: + return {"id": r.id, "status": r.status, "reason": r.reason, "requested_by": r.requested_by.name if r.requested_by else None, + "requested_at": r.requested_at.isoformat() if r.requested_at else None, + "decided_by": r.decided_by.name if r.decided_by else None, + "decided_at": r.decided_at.isoformat() if r.decided_at else None, "decision_note": r.decision_note} + + +def year_detail(view: YearView, user: User | None = None) -> dict: + out = year_out(view.year, view, user) + out["reopen_requests"] = [reopen_out(r) for r in reversed(view.year.reopen_requests)] + out["categories"] = [ + {"category": category_out(cv.category), "totals": totals_out(cv.totals), + "lines": [line_out(lv) for lv in cv.lines]} + for cv in view.categories + ] + return out diff --git a/budget-app/backend/app/summary.py b/budget-app/backend/app/summary.py new file mode 100644 index 0000000..70d5505 --- /dev/null +++ b/budget-app/backend/app/summary.py @@ -0,0 +1,66 @@ +"""Groups a year's lines by category with computed figures; shared by the API and every report.""" +from dataclasses import dataclass, field + +from sqlalchemy import select +from sqlalchemy.orm import Session, selectinload + +from .calc import LineFigures, Totals, line_figures +from .models import BudgetYear, Category, CostLine + + +@dataclass +class LineView: + line: CostLine + figures: LineFigures + + +@dataclass +class CategoryView: + category: Category + lines: list[LineView] = field(default_factory=list) + totals: Totals = field(default_factory=Totals) + + def name(self, lang: str) -> str: + return (self.category.name_pt if lang == "pt" else "") or self.category.name_en + + +@dataclass +class YearView: + year: BudgetYear + categories: list[CategoryView] + revenue: Totals + costs: Totals + + def with_lines(self, section: str | None = None) -> list[CategoryView]: + return [c for c in self.categories if c.lines and (section is None or c.category.section == section)] + + +def build_year_view(db: Session, year: BudgetYear, site_id: int | None = None) -> YearView: + categories = db.scalars(select(Category).where(Category.department_id == year.department_id) + .order_by(Category.sort_order, Category.id)).all() + lines = db.scalars( + select(CostLine) + .where(CostLine.year_id == year.id) + .options(selectinload(CostLine.site), selectinload(CostLine.category)) + .order_by(CostLine.sort_order, CostLine.id) + ).all() + views = {c.id: CategoryView(c) for c in categories} + for line in lines: + if site_id is not None and line.site_id != site_id: + continue + lv = LineView(line, line_figures(line, year.default_rate)) + cv = views.get(line.category_id) + if cv is None: # a line whose category was moved to another department + cv = views[line.category_id] = CategoryView(line.category) + cv.lines.append(lv) + cv.totals.add(lv.figures) + revenue, costs = Totals(), Totals() + shown = [] + for cv in views.values(): + if not cv.lines and not (cv.category.active and cv.category.show_when_empty): + continue + shown.append(cv) + target = revenue if cv.category.section == "revenue" else costs + for lv in cv.lines: + target.add(lv.figures) + return YearView(year, shown, revenue, costs) diff --git a/budget-app/backend/app/template.py b/budget-app/backend/app/template.py new file mode 100644 index 0000000..19d64df --- /dev/null +++ b/budget-app/backend/app/template.py @@ -0,0 +1,316 @@ +"""Blank (or pre-filled) Excel template for a department's budget, and the import of a filled one.""" +from datetime import datetime +from decimal import Decimal +from io import BytesIO + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill +from openpyxl.utils import get_column_letter +from openpyxl.worksheet.datavalidation import DataValidation +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from .audit import record +from .calc import ExpressionError, evaluate +from .models import Category, Department, Site, User + +TEMPLATE_SHEETS = {"Lines", "Linhas"} +HEADER_ROW = 8 + +TEXT = { + "en": { + "sheet": "Lines", "lists": "Lists", "help": "Instructions", + "title": "Budget template · {dept}", + "year": "Year", "rate": "Year rate (%)", "dept": "Department", + "cols": ["Category", "Line", "Description", "Site", "Prior-year actual", "Base (without adjustment)", + "Adjustment", "Adjustment value", "Reason for adjustment", "Remarks"], + "months": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + "split": "Split over months", + "adj": {"year_rate": "Year rate", "percent": "Percent", "factor": "Factor", "none": "None"}, + "group": "Group", + "instructions": [ + "How to fill in this template", + "1. On the 'Lines' sheet, set the year and the year rate (for example 5.5 for 5.5%).", + "2. Add one row per cost line, starting on row 9. Category and Line are required.", + "3. Category and Site have drop-down lists. Leave Site empty for costs shared by the group.", + "4. Prior-year actual and Base accept numbers or calculations such as =12*839.67.", + "5. Adjustment: 'Year rate' applies the year rate to the base; 'Percent' uses the Adjustment value as a %;", + " 'Factor' multiplies the base by the Adjustment value (e.g. 1.15); 'None' keeps the base.", + "6. Fill in the months (Jan to Dec) only for lines with uneven monthly amounts; the year total is then", + " the sum of the months and Base and Adjustment are ignored. Otherwise the year is split over 12.", + " To split the adjusted amount equally over some months only, list them in the last column", + " 'Split over months', for example 1-4 or Jan, Feb, Mar, Apr (leave Jan to Dec empty).", + "7. In the app, go to Budgets > Import Excel, choose the department and upload this file.", + ], + }, + "pt": { + "sheet": "Linhas", "lists": "Listas", "help": "Instruções", + "title": "Modelo de orçamento · {dept}", + "year": "Ano", "rate": "Taxa do ano (%)", "dept": "Departamento", + "cols": ["Categoria", "Linha", "Descrição", "Unidade", "Real do ano anterior", "Base (sem ajuste)", + "Ajuste", "Valor do ajuste", "Motivo do ajuste", "Observações"], + "months": ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"], + "split": "Dividir pelos meses", + "adj": {"year_rate": "Taxa do ano", "percent": "Percentagem", "factor": "Fator", "none": "Sem ajuste"}, + "group": "Grupo", + "instructions": [ + "Como preencher este modelo", + "1. Na folha 'Linhas', indique o ano e a taxa do ano (por exemplo 5,5 para 5,5%).", + "2. Acrescente uma linha por custo, a partir da linha 9. Categoria e Linha são obrigatórias.", + "3. Categoria e Unidade têm listas. Deixe a Unidade vazia para custos do grupo.", + "4. Real do ano anterior e Base aceitam números ou cálculos como =12*839,67.", + "5. Ajuste: 'Taxa do ano' aplica a taxa do ano à base; 'Percentagem' usa o Valor do ajuste como %;", + " 'Fator' multiplica a base pelo Valor do ajuste (ex.: 1,15); 'Sem ajuste' mantém a base.", + "6. Preencha os meses (Jan a Dez) só para linhas com valores mensais diferentes; o total do ano passa", + " a ser a soma dos meses e a Base e o Ajuste são ignorados. Caso contrário o ano é dividido por 12.", + " Para dividir o valor com ajuste em partes iguais só por alguns meses, indique-os na última coluna", + " 'Dividir pelos meses', por exemplo 1-4 ou Jan, Fev, Mar, Abr (deixe Jan a Dez vazios).", + "7. Na aplicação, vá a Orçamentos > Importar Excel, escolha o departamento e carregue este ficheiro.", + ], + }, +} + +MONTH_NAMES = {name.lower(): i for t in TEXT.values() for i, name in enumerate(t["months"], start=1)} +MONTH_NAMES.update({"feb": 2, "fev": 2, "apr": 4, "abr": 4, "may": 5, "mai": 5, "aug": 8, "ago": 8, + "sep": 9, "set": 9, "oct": 10, "out": 10, "dec": 12, "dez": 12}) + + +def _month(token: str) -> int | None: + token = token.strip().lower().rstrip(".") + if token.isdigit(): + return int(token) if 1 <= int(token) <= 12 else None + return MONTH_NAMES.get(token[:3]) + + +def parse_months(value) -> list[int]: + """'1-4', '1,2,3,4', 'Jan-Abr', 'jan; fev; mar' -> [1, 2, 3, 4]. Unreadable text gives [].""" + if isinstance(value, (int, float)): + value = str(int(value)) + out = set() + for part in str(value).replace(";", ",").replace("/", ",").replace(" e ", ",").replace(" and ", ",").split(","): + part = part.strip() + if not part: + continue + if "-" in part or "–" in part: + a, _, b = part.replace("–", "-").partition("-") + start, end = _month(a), _month(b) + if start is None or end is None or end < start: + return [] + out.update(range(start, end + 1)) + else: + m = _month(part) + if m is None: + return [] + out.add(m) + return sorted(out) + + +def months_text(months: list[int]) -> str: + """[1, 2, 3, 4, 7] -> '1-4, 7'.""" + months, runs = sorted(months), [] + for m in months: + if runs and m == runs[-1][1] + 1: + runs[-1][1] = m + else: + runs.append([m, m]) + return ", ".join(str(a) if a == b else f"{a}-{b}" for a, b in runs) + + +HEAD_FILL = PatternFill("solid", fgColor="1F3B63") +INPUT_FILL = PatternFill("solid", fgColor="FFF7E0") + + +def build_template(db: Session, department: Department, lang: str, year: int, rate: Decimal, + lines: list | None = None) -> bytes: + tx = TEXT.get(lang, TEXT["pt"]) + cats = db.scalars(select(Category).where(Category.department_id == department.id, Category.active) + .order_by(Category.sort_order, Category.id)).all() + sites = db.scalars(select(Site).where(Site.active).order_by(Site.name)).all() + cat_name = (lambda c: c.name_pt or c.name_en) if lang == "pt" else (lambda c: c.name_en) + + wb = Workbook() + ws = wb.active + ws.title = tx["sheet"] + ws["A1"] = tx["title"].format(dept=department.name) + ws["A1"].font = Font(bold=True, size=14) + for row, (label, value) in enumerate(((tx["dept"], department.name), (tx["year"], year), + (tx["rate"], float(rate * 100))), start=3): + ws.cell(row, 1, label).font = Font(bold=True) + c = ws.cell(row, 2, value) + if row > 3: + c.fill = INPUT_FILL + ws["B5"].number_format = "0.00" + headers = tx["cols"] + tx["months"] + [tx["split"]] + for col, label in enumerate(headers, start=1): + c = ws.cell(HEADER_ROW, col, label) + c.font, c.fill = Font(bold=True, color="FFFFFF"), HEAD_FILL + c.alignment = Alignment(wrap_text=True, vertical="center") + ws.row_dimensions[HEADER_ROW].height = 32 + + lists = wb.create_sheet(tx["lists"]) + for i, c in enumerate(cats, start=1): + lists.cell(i, 1, cat_name(c)) + for i, s in enumerate(sites, start=1): + lists.cell(i, 2, s.name) + for i, label in enumerate(tx["adj"].values(), start=1): + lists.cell(i, 3, label) + lists.sheet_state = "hidden" + + last = HEADER_ROW + max(500, len(lines or []) + 200) + rng = lambda col: f"{get_column_letter(col)}{HEADER_ROW + 1}:{get_column_letter(col)}{last}" + for col, src, n in ((1, "A", len(cats)), (4, "B", len(sites)), (7, "C", 4)): + if n: + dv = DataValidation(type="list", formula1=f"'{tx['lists']}'!${src}$1:${src}${n}", allow_blank=True) + dv.error, dv.showErrorMessage = "", col != 1 # unknown categories are allowed and created on import + ws.add_data_validation(dv) + dv.add(rng(col)) + + row = HEADER_ROW + 1 + for line in lines or []: + f = line.figures + ln = line.line + values = [cat_name(ln.category), ln.name, ln.description, ln.site.name if ln.site else "", + float(f.prior), float(f.base) if ln.monthly_kind != "manual" else None, + tx["adj"][ln.adj_kind] if ln.monthly_kind != "manual" else None, + float(ln.adj_value) if ln.adj_kind in ("percent", "factor") else None, + ln.adj_reason, ln.remarks] + if ln.monthly_kind == "manual": + values += [float(m) for m in f.months] + elif ln.monthly_kind == "selected" and ln.monthly_months: + values += [None] * 12 + [months_text(ln.monthly_months)] + for col, v in enumerate(values, start=1): + if v not in (None, ""): + ws.cell(row, col, v) + row += 1 + for col in range(5, 7): + for r in range(HEADER_ROW + 1, last + 1): + ws.cell(r, col).number_format = "#,##0.00" + for col, width in zip(range(1, 11), (34, 40, 30, 14, 18, 20, 16, 14, 30, 36)): + ws.column_dimensions[get_column_letter(col)].width = width + for col in range(11, 23): + ws.column_dimensions[get_column_letter(col)].width = 11 + ws.column_dimensions[get_column_letter(23)].width = 22 + ws.freeze_panes = ws.cell(HEADER_ROW + 1, 3) + + helpws = wb.create_sheet(tx["help"], 0) + for i, text in enumerate(tx["instructions"], start=1): + helpws.cell(i, 1, text).font = Font(bold=(i == 1), size=13 if i == 1 else 11) + helpws.cell(len(tx["instructions"]) + 2, 1, f"{tx['dept']}: {department.name} · {datetime.now():%Y-%m-%d}").font = Font(color="888888") + helpws.column_dimensions["A"].width = 110 + wb.active = 1 + + buf = BytesIO() + wb.save(buf) + return buf.getvalue() + + +def _norm(value) -> str: + return str(value or "").strip().lower() + + +def _amount(value, warnings, where) -> tuple[str, Decimal]: + if value is None or value == "": + return "0", Decimal(0) + if isinstance(value, (int, float)): + return str(value), Decimal(str(value)) + try: + return str(value).strip().lstrip("="), evaluate(str(value)) + except ExpressionError: + warnings.append(f"{where}: '{value}' is not a number, used 0") + return "0", Decimal(0) + + +def import_template(db: Session, wb, wb_formulas, user: User | None, department_id: int, year: int | None, + replace: bool, filename: str = "", check_only: bool = False): + from .importer import ImportErrorWithDetail, ImportResult, _category_index, prepare_year + from .models import CostLine + + ws = next(w for w in wb.worksheets if w.title in TEMPLATE_SHEETS) + wsf = wb_formulas[ws.title] + + def cell(r: int, c: int): + # Excel stores the computed value of =4*300; files written by other tools may only hold the formula. + v = ws.cell(r, c).value + return wsf.cell(r, c).value if v is None else v + labels = {**{_norm(t["year"]): "year" for t in TEXT.values()}, **{_norm(t["rate"]): "rate" for t in TEXT.values()}} + header_row, meta = None, {} + for r in range(1, 30): + a = _norm(ws.cell(r, 1).value) + if a in labels: + meta[labels[a]] = ws.cell(r, 2).value + if a in (_norm(t["cols"][0]) for t in TEXT.values()): + header_row = r + break + if header_row is None: + raise ImportErrorWithDetail("template_header_missing") + + target_year = year or (int(meta["year"]) if isinstance(meta.get("year"), (int, float)) else None) + if not target_year: + raise ImportErrorWithDetail("year_not_found") + rate_value = meta.get("rate") + try: + rate = evaluate(str(rate_value)) / 100 if rate_value not in (None, "") else Decimal(0) + except ExpressionError: + rate = Decimal(0) + + result = ImportResult(year=target_year) + if check_only: + return result + by = prepare_year(db, department_id, target_year, rate, replace, user, filename) + cats = _category_index(db, department_id) + sites = {_norm(s.name): s for s in db.scalars(select(Site))} + adj_kinds = {_norm(label): kind for t in TEXT.values() for kind, label in t["adj"].items()} + adj_kinds.update({"%": "percent", "factor ×": "factor", "fator ×": "factor"}) + group_words = {_norm(t["group"]) for t in TEXT.values()} + next_order = (db.scalar(select(func.max(Category.sort_order)).where(Category.department_id == department_id)) or 0) + 10 + + order = 0 + for r in range(header_row + 1, ws.max_row + 1): + row = [cell(r, c) for c in range(1, 24)] + cat_label, name = str(row[0] or "").strip(), str(row[1] or "").strip() + if not cat_label and not name: + continue + where = f"{ws.title} row {r}" + if not cat_label or not name: + result.warnings.append(f"{where}: category and line are required, row skipped") + continue + cat = cats.get(cat_label.lower()) + if cat is None: + cat = Category(department_id=department_id, name_en=cat_label, name_pt=cat_label, section="cost", + sort_order=next_order) + next_order += 10 + db.add(cat) + db.flush() + cats[cat_label.lower()] = cat + result.categories_created.append(cat_label) + site = None + if row[3] not in (None, "") and _norm(row[3]) not in group_words: + site = sites.get(_norm(row[3])) + if site is None: + result.warnings.append(f"{where}: unknown site '{row[3]}', saved as group") + prior_expr, prior = _amount(row[4], result.warnings, where) + base_expr, base = _amount(row[5], result.warnings, where) + kind = adj_kinds.get(_norm(row[6]), "year_rate") if row[6] not in (None, "") else "year_rate" + _, adj_value = _amount(row[7], result.warnings, where) + months = row[10:22] + manual = any(m not in (None, "") for m in months) + month_values = [float(_amount(m, result.warnings, where)[1]) for m in months] if manual else None + selected = None + if not manual and row[22] not in (None, ""): + selected = parse_months(row[22]) + if not selected: + result.warnings.append(f"{where}: could not read the months '{row[22]}', split over 12") + order += 10 + db.add(CostLine( + year_id=by.id, category_id=cat.id, site_id=site.id if site else None, name=name, + description=str(row[2] or ""), remarks=str(row[9] or ""), prior_expr=prior_expr, prior_actual=prior, + base_expr=base_expr, base_amount=base, adj_kind="none" if manual else kind, adj_value=adj_value, + adj_reason=str(row[8] or ""), monthly_values=month_values, monthly_months=selected, + monthly_kind="manual" if manual else "selected" if selected else "even", + sort_order=order)) + result.lines += 1 + + record(db, user, "year", by.id, "import", f"Imported {result.lines} lines from template into {target_year}", + year_id=by.id) + db.commit() + return result diff --git a/budget-app/backend/docker-entrypoint.sh b/budget-app/backend/docker-entrypoint.sh new file mode 100644 index 0000000..84291e2 --- /dev/null +++ b/budget-app/backend/docker-entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +set -e +python -m app.preupgrade +alembic upgrade head +python -m app.bootstrap +exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips='*' --workers "${API_WORKERS:-2}" diff --git a/budget-app/backend/requirements.txt b/budget-app/backend/requirements.txt new file mode 100644 index 0000000..4f32324 --- /dev/null +++ b/budget-app/backend/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.2 +SQLAlchemy==2.0.40 +psycopg[binary]==3.2.6 +alembic==1.15.2 +openpyxl==3.1.5 +weasyprint==65.1 +Jinja2==3.1.6 +PyJWT==2.10.1 +bcrypt==4.3.0 +python-multipart==0.0.20 +cryptography==44.0.2 +segno==1.6.1 diff --git a/budget-app/backend/tests/conftest.py b/budget-app/backend/tests/conftest.py new file mode 100644 index 0000000..a9a86bf --- /dev/null +++ b/budget-app/backend/tests/conftest.py @@ -0,0 +1,40 @@ +import os +import sys +from pathlib import Path + +import pytest + +DB = Path(__file__).parent / "test.db" +os.environ["DATABASE_URL"] = f"sqlite:///{DB}" +os.environ["ADMIN_EMAIL"] = "admin@test.local" +os.environ["ADMIN_PASSWORD"] = "admin-pass-123" +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from fastapi.testclient import TestClient # noqa: E402 + +from app.bootstrap import bootstrap # noqa: E402 +from app.db import Base, engine # noqa: E402 +from app.main import app # noqa: E402 + +FIXTURE = Path(__file__).parent / "fixtures" / "budget_2027.xlsx" +H = {"X-Requested-With": "budget-app"} + + +@pytest.fixture() +def client(): + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + bootstrap() + with TestClient(app) as c: + r = c.post("/api/auth/login", json={"email": "admin@test.local", "password": "admin-pass-123"}, headers=H) + assert r.status_code == 200 + c.headers.update(H) + yield c + + +@pytest.fixture() +def imported(client): + with FIXTURE.open("rb") as f: + r = client.post("/api/years/import", files={"file": ("b.xlsx", f, "application/octet-stream")}) + assert r.status_code == 200, r.text + return r.json()["year"]["id"] diff --git a/budget-app/backend/tests/fixtures/budget_2027.xlsx b/budget-app/backend/tests/fixtures/budget_2027.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d54b863e20bcec55776b9a4dfa407b6950c42033 GIT binary patch literal 141514 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

dD)8Hulf$M7 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

C_5fdz4>@JaKXoP9rzBOd}^!uNCN%mrCb? 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 Y)Nz1|Az*w3`0J#>z*r#$hF>uL5ARur{{R30 literal 0 HcmV?d00001 diff --git a/budget-app/backend/tests/test_api.py b/budget-app/backend/tests/test_api.py new file mode 100644 index 0000000..890a5d2 --- /dev/null +++ b/budget-app/backend/tests/test_api.py @@ -0,0 +1,113 @@ +from io import BytesIO + +import openpyxl +import pytest + +# Category totals from the "Capex e Custos 2027 v4" PDF. Lines are rounded to cents before +# summing, so a category can differ from the PDF by a cent or two. +PDF_TOTALS = { + "Contract Services": (600758.54, 615940.02), + "Cost of Cell Phones": (96532.00, 111011.80), + "Cost of Internet Services": (74160.00, 85284.00), + "Cost of Local Calls": (5575.00, 6269.13), + "Cost of Long Distance Calls": (11415.00, 12488.47), + "Other Cost of Services": (176847.00, 185226.36), + "Hardware": (37636.00, 83361.00), + "Property Operation and Maintenance": (31283.00, 55015.83), + "Other IT Equipment": (72566.00, 70747.24), + "Travel-Other": (14400.00, 16880.00), +} + + +def test_requires_login(): + from fastapi.testclient import TestClient + + from app.main import app + assert TestClient(app).get("/api/years").status_code == 401 + + +def test_writes_need_header(client): + r = client.post("/api/years", json={"year": 2030}, headers={"X-Requested-With": ""}) + assert r.status_code == 403 + + +def test_import_matches_pdf(client, imported): + y = client.get(f"/api/years/{imported}").json() + assert y["year"] == 2027 and y["default_rate"] == pytest.approx(0.055) + got = {c["category"]["name_en"]: c["totals"] for c in y["categories"] if c["lines"]} + for name, (prior, adjusted) in PDF_TOTALS.items(): + assert got[name]["prior"] == pytest.approx(prior, abs=0.005) + assert got[name]["adjusted"] == pytest.approx(adjusted, abs=0.02) + assert y["costs"]["prior"] == pytest.approx(1121172.54, abs=0.005) + assert y["costs"]["adjusted"] == pytest.approx(1242223.85, abs=0.05) + assert sum(len(c["lines"]) for c in y["categories"]) == 54 + hw = next(c for c in y["categories"] if c["category"]["name_en"] == "Hardware") + oriente = next(l for l in hw["lines"] if "ORIENTE" in l["name"]) + assert oriente["adj_kind"] == "factor" and oriente["adj_value"] == pytest.approx(3.055) + assert "Hardware price increase" in oriente["adj_reason"] + protel = next(l for c in y["categories"] for l in c["lines"] if l["name"] == "Protel") + assert protel["prior_expr"] == "5512*12" + + +def test_line_crud_and_month_spread(client, imported): + cats = client.get("/api/categories").json() + training = next(c for c in cats if c["name_en"] == "Training") + r = client.post(f"/api/years/{imported}/lines", json={ + "category_id": training["id"], "name": "Microsoft certification", "prior_expr": "1000", + "base_expr": "=4*300", "adj_kind": "percent", "adj_value": 10}) + assert r.status_code == 200, r.text + line_id = r.json()["id"] + y = client.get(f"/api/years/{imported}").json() + line = next(l for c in y["categories"] for l in c["lines"] if l["id"] == line_id) + assert line["base"] == 1200 and line["adjusted"] == 1320 and sum(line["months"]) == pytest.approx(1320) + assert client.put(f"/api/lines/{line_id}", json={"base_expr": "abc"}).status_code == 400 + r = client.put(f"/api/lines/{line_id}", json={"monthly_kind": "manual"}) + assert r.status_code == 200 + y = client.get(f"/api/years/{imported}").json() + line = next(l for c in y["categories"] for l in c["lines"] if l["id"] == line_id) + assert line["monthly_values"][0] == 110 and line["adjusted"] == pytest.approx(1320) + assert client.delete(f"/api/lines/{line_id}").status_code == 200 + audit = client.get(f"/api/years/{imported}/audit").json() + assert [a["action"] for a in audit[:3]] == ["delete", "update", "create"] + + +def test_status_workflow_and_roles(client, imported): + it = client.get("/api/departments").json()[0]["id"] + r = client.post("/api/users", json={"email": "ed@test.local", "name": "Ed", "role": "editor", + "password": "editor-pass-1", "department_id": it}) + assert r.status_code == 200 + from fastapi.testclient import TestClient + + from app.main import app + ed = TestClient(app) + ed.headers.update({"X-Requested-With": "budget-app"}) + assert ed.post("/api/auth/login", json={"email": "ed@test.local", "password": "editor-pass-1"}).status_code == 200 + assert ed.get("/api/users").status_code == 403 + assert ed.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 200 + assert ed.post(f"/api/years/{imported}/status", json={"action": "approve"}).status_code == 403 + line = client.get(f"/api/years/{imported}").json()["categories"][1]["lines"][0] + assert ed.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code == 409 + assert client.post(f"/api/years/{imported}/status", json={"action": "approve"}).json()["status"] == "approved" + assert client.post(f"/api/years/{imported}/status", json={"action": "reopen"}).status_code == 400 # reason needed + assert client.post(f"/api/years/{imported}/status", json={"action": "reopen", "comment": "new contract"}).json()["status"] == "draft" + + +def test_rollover(client, imported): + r = client.post("/api/years", json={"year": 2028, "copy_from_id": imported}) + assert r.status_code == 200 + y = client.get(f"/api/years/{r.json()['id']}").json() + assert y["costs"]["prior"] == pytest.approx(1242223.85, abs=0.05) + + +@pytest.mark.parametrize("kind", ["cost-report", "budget"]) +@pytest.mark.parametrize("lang", ["pt", "en"]) +def test_reports(client, imported, kind, lang): + html = client.get(f"/api/years/{imported}/reports/{kind}.html?lang={lang}") + assert html.status_code == 200 + assert ("= 2 + pdf = client.get(f"/api/years/{imported}/reports/{kind}.pdf?lang={lang}") + assert pdf.status_code == 200 and pdf.content[:4] == b"%PDF" diff --git a/budget-app/backend/tests/test_calc.py b/budget-app/backend/tests/test_calc.py new file mode 100644 index 0000000..866ed79 --- /dev/null +++ b/budget-app/backend/tests/test_calc.py @@ -0,0 +1,30 @@ +from decimal import Decimal + +import pytest + +from app.calc import ExpressionError, evaluate, even_spread + + +@pytest.mark.parametrize("text,expected", [ + ("=5512*12", "66144"), + ("3292.56+(12*839.67)+(12*148.19)", "15146.88"), + ("4 839,65", "4839.65"), + ("1.234,50", "1234.50"), + ("1,234.50", "1234.50"), + ("", "0"), + ("-10/4", "-2.5"), +]) +def test_evaluate(text, expected): + assert evaluate(text) == Decimal(expected) + + +@pytest.mark.parametrize("text", ["__import__('os')", "2**10", "1/0", "abc", "1;2"]) +def test_evaluate_rejects(text): + with pytest.raises(ExpressionError): + evaluate(text) + + +def test_even_spread_adds_up(): + months = even_spread(Decimal("100.00")) + assert sum(months) == Decimal("100.00") + assert months[0] == Decimal("8.33") and months[-1] == Decimal("8.37") diff --git a/budget-app/backend/tests/test_departments.py b/budget-app/backend/tests/test_departments.py new file mode 100644 index 0000000..5d56100 --- /dev/null +++ b/budget-app/backend/tests/test_departments.py @@ -0,0 +1,127 @@ +from io import BytesIO + +import openpyxl +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +H = {"X-Requested-With": "budget-app"} + + +def login(email, password): + c = TestClient(app) + c.headers.update(H) + assert c.post("/api/auth/login", json={"email": email, "password": password}).status_code == 200 + return c + + +@pytest.fixture() +def org(client, imported): + """IT (with the imported 2027 budget) and F&B, one editor and one approver in each.""" + it = client.get("/api/departments").json()[0] + fb = client.post("/api/departments", json={"name": "F&B", "categories_from": "defaults"}).json() + users = {} + for key, role, dept in (("it_ed", "editor", it), ("it_ap", "approver", it), + ("fb_ed", "editor", fb), ("fb_ap", "approver", fb)): + r = client.post("/api/users", json={"email": f"{key}@t.local", "name": key, "role": role, + "password": "password-123", "department_id": dept["id"]}) + assert r.status_code == 200, r.text + users[key] = login(f"{key}@t.local", "password-123") + return {"it": it, "fb": fb, "year": imported, **users} + + +def test_categories_are_per_department(client, org): + it_cats = client.get(f"/api/categories?department_id={org['it']['id']}").json() + fb_cats = client.get(f"/api/categories?department_id={org['fb']['id']}").json() + assert len(it_cats) == len(fb_cats) == 50 + assert not {c["id"] for c in it_cats} & {c["id"] for c in fb_cats} + # a line in an IT budget cannot use an F&B category + r = client.post(f"/api/years/{org['year']}/lines", json={"category_id": fb_cats[0]["id"], "name": "x"}) + assert r.status_code == 400 and r.json()["detail"] == "invalid_category" + # the same year can exist once per department + r = org["fb_ed"].post("/api/years", json={"year": 2027}) + assert r.status_code == 200 and r.json()["department"] == "F&B" + + +def test_other_departments_can_only_view(org): + fb = org["fb_ed"] + y = fb.get(f"/api/years/{org['year']}").json() + assert y["department"] == "IT" and y["actions"]["edit"] is False + line = y["categories"][1]["lines"][0] + assert fb.put(f"/api/lines/{line['id']}", json={"name": "hack"}).status_code == 403 + assert fb.post(f"/api/years/{org['year']}/status", json={"action": "submit"}).status_code == 403 + assert fb.get(f"/api/years/{org['year']}/reports/cost-report.xlsx").status_code == 200 + assert org["it_ed"].put(f"/api/lines/{line['id']}", json={"remarks": "ok"}).status_code == 200 + + +def test_custom_workflow(client, org): + it = org["it"]["id"] + users = {u["email"]: u["id"] for u in client.get("/api/users").json()} + r = client.put(f"/api/departments/{it}/workflow", json={"steps": [ + {"name": "Department head", "approver_mode": "role"}, + {"name": "Finance", "approver_mode": "users", "approver_ids": [users["fb_ap@t.local"]]}, + ]}) + assert r.status_code == 200 and [s["name"] for s in r.json()["steps"]] == ["Department head", "Finance"] + y = org["year"] + assert org["it_ed"].post(f"/api/years/{y}/status", json={"action": "submit"}).json()["current_step"] == 0 + # step 1: only IT approvers; the F&B approver is not on this step + assert org["fb_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403 + assert org["it_ed"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403 + r = org["it_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}) + assert r.json()["status"] == "in_review" and r.json()["current_step"] == 1 + # step 2: only the named user + assert org["it_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}).status_code == 403 + r = org["fb_ap"].post(f"/api/years/{y}/status", json={"action": "approve"}) + assert r.json()["status"] == "approved" + # the workflow cannot be changed while a budget is in review + org["it_ap"].post(f"/api/years/{y}/status", json={"action": "reopen", "comment": "fix a line"}) + org["it_ed"].post(f"/api/years/{y}/status", json={"action": "submit"}) + assert client.put(f"/api/departments/{it}/workflow", json={"steps": []}).status_code == 409 + + +def test_template_roundtrip(client, org): + fb = org["fb"]["id"] + r = org["fb_ed"].get(f"/api/departments/{fb}/template.xlsx?lang=pt&year=2028&rate=3") + assert r.status_code == 200 + wb = openpyxl.load_workbook(BytesIO(r.content)) + assert wb.sheetnames == ["Instruções", "Linhas", "Listas"] + ws = wb["Linhas"] + rows = [ + ["Formação", "Curso de barista", "", "Chiado", 1000, "=4*300", "Percentagem", 10, "", "nota"], + ["Vinhos", "Carta de vinhos", "", "", 5000, 5000, "Fator", 1.2, "Preços", ""], + ["Receitas", "Eventos", "", "Grupo", 0, None, None, None, "", ""] + [100] * 12, + ["Nova categoria", "Algo novo", "", "Lua", 10, 10, "", None, "", ""], + ] + for i, row in enumerate(rows): + for j, v in enumerate(row): + ws.cell(9 + i, j + 1, v) + buf = BytesIO() + wb.save(buf) + r = org["fb_ed"].post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())}, + data={"department_id": str(fb)}) + assert r.status_code == 200, r.text + res = r.json() + assert res["lines"] == 4 and res["categories_created"] == ["Nova categoria"] + assert any("Lua" in w for w in res["warnings"]) + y = client.get(f"/api/years/{res['year']['id']}").json() + assert y["year"] == 2028 and y["default_rate"] == pytest.approx(0.03) and y["department"] == "F&B" + lines = {l["name"]: l for c in y["categories"] for l in c["lines"]} + assert lines["Curso de barista"]["adjusted"] == 1320 + assert lines["Carta de vinhos"]["adjusted"] == 6000 + assert lines["Eventos"]["adjusted"] == 1200 and lines["Eventos"]["monthly_kind"] == "manual" + assert lines["Algo novo"]["adjusted"] == pytest.approx(10.3) + # the IT editor cannot import into F&B + r = org["it_ed"].post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())}, + data={"department_id": str(fb), "replace": "true"}) + assert r.status_code == 403 + + +def test_prefilled_template_reimports_same_totals(client, org): + it = org["it"]["id"] + r = client.get(f"/api/departments/{it}/template.xlsx?lang=en&from_year_id={org['year']}&year=2029&rate=5.5") + res = client.post("/api/years/import", files={"file": ("t.xlsx", r.content)}, data={"department_id": str(it)}).json() + assert res["lines"] == 54 + a = client.get(f"/api/years/{org['year']}").json()["costs"] + b = client.get(f"/api/years/{res['year']['id']}").json()["costs"] + assert a["adjusted"] == pytest.approx(b["adjusted"], abs=0.01) and a["prior"] == b["prior"] diff --git a/budget-app/backend/tests/test_mail.py b/budget-app/backend/tests/test_mail.py new file mode 100644 index 0000000..3c87739 --- /dev/null +++ b/budget-app/backend/tests/test_mail.py @@ -0,0 +1,191 @@ +import email + +import pytest +from fastapi.testclient import TestClient + +from app import mailer +from app.main import app + +H = {"X-Requested-With": "budget-app"} + + +class FakeSMTP: + sent: list = [] + fail_login = False + + def __init__(self, host, port, timeout=None, context=None): + self.host, self.port = host, port + + def ehlo(self): + pass + + def starttls(self, context=None): + pass + + def login(self, user, password): + if FakeSMTP.fail_login or password != "smtp-secret": + import smtplib + raise smtplib.SMTPAuthenticationError(535, b"bad credentials") + + def send_message(self, msg, from_addr=None, to_addrs=None): + FakeSMTP.sent.append((from_addr, list(to_addrs), msg)) + return {} + + def quit(self): + pass + + +class FakeIMAP: + appended: list = [] + folders = [b'(\\HasNoChildren) "/" "INBOX"', b'(\\HasNoChildren \\Sent) "/" "Itens Enviados"', + b'(\\HasNoChildren) "/" "Sent"'] + + def __init__(self, host, port, ssl_context=None, timeout=None): + pass + + def login(self, user, password): + assert password == "smtp-secret" + + def list(self): + return "OK", list(FakeIMAP.folders) + + def append(self, folder, flags, date, data): + FakeIMAP.appended.append((folder, flags, data)) + return "OK", [b"APPEND completed"] + + def logout(self): + pass + + +@pytest.fixture() +def fake_mail(monkeypatch): + FakeSMTP.sent, FakeIMAP.appended, FakeSMTP.fail_login = [], [], False + monkeypatch.setattr(mailer.smtplib, "SMTP", FakeSMTP) + monkeypatch.setattr(mailer.smtplib, "SMTP_SSL", FakeSMTP) + monkeypatch.setattr(mailer.imaplib, "IMAP4_SSL", FakeIMAP) + monkeypatch.setattr(mailer.imaplib, "IMAP4", FakeIMAP) + + +def configure(client, **extra): + body = {"enabled": True, "smtp_host": "mail.local", "smtp_port": 587, "smtp_security": "starttls", + "smtp_username": "budget@local.test", "smtp_password": "smtp-secret", + "from_address": "budget@local.test", "from_name": "Budget", "save_to_sent": True, + "imap_host": "mail.local", "imap_port": 993, "imap_security": "ssl", **extra} + r = client.put("/api/mail/settings", json=body) + assert r.status_code == 200, r.text + return r.json() + + +def test_settings_hide_and_encrypt_passwords(client, fake_mail): + out = configure(client) + assert out["smtp_password_set"] and "smtp_password" not in out and out["ready"] + from app.db import SessionLocal + from app.models import MailSettings + with SessionLocal() as db: + stored = db.get(MailSettings, 1).smtp_password + assert stored and "smtp-secret" not in stored and mailer.decrypt(stored) == "smtp-secret" + # saving again without a password keeps it + configure(client, smtp_password="") + assert client.post("/api/mail/test", json={}).json() == {"smtp": "ok", "imap": "ok", "sent_folder": "Itens Enviados"} + + +def test_send_report_saves_sent_copy_and_logs(client, imported, fake_mail): + configure(client) + r = client.post(f"/api/years/{imported}/email", json={ + "to": ["cfo@example.com; ops@example.com"], "cc": ["boss@example.com"], "subject": "Budget 2027", + "message": "Segue o orçamento.", "lang": "pt", "copy_me": True, + "reports": [{"kind": "cost-report", "fmt": "pdf"}, {"kind": "budget", "fmt": "xlsx"}]}) + assert r.status_code == 200, r.text + log = r.json() + assert log["status"] == "sent" and log["sent_copy"] == "saved" and log["sent_copy_detail"] == "Itens Enviados" + assert log["to"] == ["cfo@example.com", "ops@example.com"] + assert log["cc"] == ["boss@example.com", "admin@test.local"] + + sender, rcpts, msg = FakeSMTP.sent[0] + assert sender == "budget@local.test" and len(rcpts) == 4 + names = [p.get_filename() for p in msg.iter_attachments()] + assert names == ["Budget_IT_2027.xlsx", "Capex_e_Custos_IT_2027.pdf"] + folder, flags, data = FakeIMAP.appended[0] + assert folder == '"Itens Enviados"' and flags == r"(\Seen)" + copy = email.message_from_bytes(data) + assert copy["Message-ID"] == msg["Message-ID"] and copy["Subject"] == "Budget 2027" + + rows = client.get("/api/mail/log").json() + assert rows["total"] == 1 and rows["rows"][0]["user"] == "Administrator" + assert [a["name"] for a in rows["rows"][0]["attachments"]] == names + assert "cfo@example.com" in client.get("/api/mail/log.csv").text + acts = client.get("/api/activity?entity=mail").json()["rows"] + assert acts[0]["action"] == "send" and "cfo@example.com" in acts[0]["summary"] + + +def test_failed_send_is_logged(client, imported, fake_mail): + configure(client) + FakeSMTP.fail_login = True + r = client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"], + "reports": [{"kind": "budget", "fmt": "pdf"}]}) + assert r.status_code == 502 and r.json()["detail"] == "smtp_auth" + row = client.get("/api/mail/log?status=failed").json()["rows"][0] + assert row["error"].startswith("smtp_auth") and row["sent_copy"] == "off" + assert not FakeIMAP.appended + + +def test_sent_folder_found_by_name(fake_mail, monkeypatch): + monkeypatch.setattr(FakeIMAP, "folders", [b'(\\HasNoChildren) "." "INBOX"', b'(\\HasNoChildren) "." "INBOX.Sent"']) + assert mailer.find_sent_folder(FakeIMAP("h", 1), "") == "INBOX.Sent" + with pytest.raises(mailer.MailError) as exc: + mailer.find_sent_folder(FakeIMAP("h", 1), "Outbox") + assert exc.value.code == "sent_folder_not_found" + + +def test_mail_rules(client, imported, fake_mail): + # not configured yet + r = client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"], + "reports": [{"kind": "budget", "fmt": "pdf"}]}) + assert r.status_code == 409 + configure(client) + bad = client.post(f"/api/years/{imported}/email", json={"to": ["not-an-address"], + "reports": [{"kind": "budget", "fmt": "pdf"}]}) + assert bad.status_code == 400 and bad.json()["detail"].startswith("invalid_address") + assert client.post(f"/api/years/{imported}/email", json={"to": ["a@example.com"], "reports": []}).status_code == 400 + + client.post("/api/users", json={"email": "v@t.local", "name": "Viewer", "role": "viewer", + "password": "password-123"}) + viewer = TestClient(app) + viewer.headers.update(H) + viewer.post("/api/auth/login", json={"email": "v@t.local", "password": "password-123"}) + # any signed-in user may email a report; only admins see the settings and the logs + assert viewer.get("/api/mail/status").json()["enabled"] is True + assert viewer.post(f"/api/years/{imported}/email", json={ + "to": ["a@example.com"], "reports": [{"kind": "budget", "fmt": "xlsx"}]}).status_code == 200 + for url in ("/api/mail/settings", "/api/mail/log", "/api/mail/log.csv", "/api/activity", "/api/activity.csv"): + assert viewer.get(url).status_code == 403, url + assert viewer.put("/api/mail/settings", json={"enabled": False}).status_code == 403 + + +def test_only_admin_deletes_budgets(client, imported): + client.post("/api/users", json={"email": "ap@t.local", "name": "Approver", "role": "approver", + "password": "password-123", "department_id": 1}) + ap = TestClient(app) + ap.headers.update(H) + ap.post("/api/auth/login", json={"email": "ap@t.local", "password": "password-123"}) + assert ap.get(f"/api/years/{imported}").json()["actions"]["delete"] is False + assert ap.delete(f"/api/years/{imported}").status_code == 403 + # an admin can delete it even after approval + assert client.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 200 + assert client.get(f"/api/years/{imported}").json()["actions"]["delete"] is True + assert client.delete(f"/api/years/{imported}").status_code == 200 + assert client.get(f"/api/years/{imported}").status_code == 404 + row = client.get("/api/activity?action=delete&entity=year").json()["rows"][0] + assert row["user"] == "Administrator" and "IT 2027" in row["summary"] and row["budget_exists"] is False + + +def test_activity_log_records_who_does_what(client, imported): + client.post("/api/auth/login", json={"email": "admin@test.local", "password": "wrong"}) + client.get(f"/api/years/{imported}/reports/budget.xlsx") + data = client.get("/api/activity").json() + actions = [(r["entity"], r["action"]) for r in data["rows"]] + assert ("report", "download") in actions and ("user", "login_failed") in actions and ("year", "import") in actions + assert data["rows"][0]["budget"] == "IT 2027" or data["rows"][0]["entity"] == "user" + only = client.get("/api/activity?entity=report").json() + assert only["total"] == 1 and only["rows"][0]["budget"] == "IT 2027" + assert "budget.xlsx" in client.get("/api/activity.csv?entity=report").text diff --git a/budget-app/backend/tests/test_months.py b/budget-app/backend/tests/test_months.py new file mode 100644 index 0000000..6ed0e70 --- /dev/null +++ b/budget-app/backend/tests/test_months.py @@ -0,0 +1,71 @@ +from decimal import Decimal +from io import BytesIO + +import openpyxl + +from app.calc import selected_spread + + +def test_selected_spread_in_cents(): + months = selected_spread(Decimal("1000.00"), [1, 2, 3]) + assert months[:3] == [Decimal("333.33"), Decimal("333.33"), Decimal("333.34")] + assert sum(months) == Decimal("1000.00") and months[3:] == [0] * 9 + assert selected_spread(Decimal("120"), [4, 1, 3, 2, 2]) == [30, 30, 30, 30] + [0] * 8 + assert selected_spread(Decimal("12"), []) == [1] * 12 + + +def _first_line(client, year_id): + return client.get(f"/api/years/{year_id}").json()["categories"][0]["lines"][0] + + +def test_line_split_over_chosen_months(client, imported): + line = _first_line(client, imported) + r = client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": [4, 1, 2, 3, 3]}) + assert r.status_code == 200, r.text + after = _first_line(client, imported) + assert after["monthly_kind"] == "selected" and after["monthly_months"] == [1, 2, 3, 4] + assert after["adjusted"] == line["adjusted"] # the year total does not change, only when it is spent + share = round(line["adjusted"] / 4, 2) + assert after["months"][0] == share and after["months"][4:] == [0] * 8 + assert round(sum(after["months"]), 2) == line["adjusted"] + + # the year's monthly totals follow; the monthly budget report shows the months + year = client.get(f"/api/years/{imported}").json() + assert year["costs"]["adjusted"] == client.get(f"/api/years/{imported}").json()["costs"]["adjusted"] + assert client.get(f"/api/years/{imported}/reports/budget.xlsx").status_code == 200 + + assert client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": []}).status_code == 400 + assert client.put(f"/api/lines/{line['id']}", json={"monthly_months": [13]}).status_code == 400 + + # rolling over to next year keeps the chosen months + nxt = client.post("/api/years", json={"year": 2028, "copy_from_id": imported}).json() + copied = _first_line(client, nxt["id"]) + assert copied["monthly_kind"] == "selected" and copied["monthly_months"] == [1, 2, 3, 4] + + # back to 12 months + client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "even", "monthly_months": None}) + assert _first_line(client, imported)["months"][11] != 0 + + +def test_template_keeps_chosen_months(client, imported): + line = _first_line(client, imported) + client.put(f"/api/lines/{line['id']}", json={"monthly_kind": "selected", "monthly_months": [1, 2, 3, 4, 7]}) + dept = client.get("/api/departments").json()[0]["id"] + r = client.get(f"/api/departments/{dept}/template.xlsx?lang=pt&from_year_id={imported}&year=2030") + ws = openpyxl.load_workbook(BytesIO(r.content))["Linhas"] + assert ws.cell(8, 23).value == "Dividir pelos meses" + assert "1-4, 7" in [ws.cell(row, 23).value for row in range(9, 70)] + # a hand-written month list on a row without typed months + free = next(r for r in range(9, 70) if ws.cell(r, 2).value and ws.cell(r, 23).value is None + and all(ws.cell(r, c).value is None for c in range(11, 23))) + ws.cell(free, 23, "Set-Dez") + buf = BytesIO() + ws.parent.save(buf) + res = client.post("/api/years/import", files={"file": ("t.xlsx", buf.getvalue())}, + data={"department_id": str(dept)}).json() + lines = [ln for c in client.get(f"/api/years/{res['year']['id']}").json()["categories"] for ln in c["lines"]] + picked = [ln["monthly_months"] for ln in lines if ln["monthly_kind"] == "selected"] + assert [1, 2, 3, 4, 7] in picked and [9, 10, 11, 12] in picked + a = client.get(f"/api/years/{imported}").json()["costs"] + b = client.get(f"/api/years/{res['year']['id']}").json()["costs"] + assert abs(a["adjusted"] - b["adjusted"]) < 0.01 diff --git a/budget-app/backend/tests/test_v5.py b/budget-app/backend/tests/test_v5.py new file mode 100644 index 0000000..5fe71f4 --- /dev/null +++ b/budget-app/backend/tests/test_v5.py @@ -0,0 +1,127 @@ +from fastapi.testclient import TestClient + +from app.main import app +from tests.test_mail import fake_mail # noqa: F401 (fixture) + +H = {"X-Requested-With": "budget-app"} + + +def user(client, email, role, dept=1): + client.post("/api/users", json={"email": email, "name": email.split("@")[0], "role": role, + "password": "password-123", "department_id": dept}) + c = TestClient(app) + c.headers.update(H) + assert c.post("/api/auth/login", json={"email": email, "password": "password-123"}).status_code == 200 + return c + + +def approve(client, year_id): + assert client.post(f"/api/years/{year_id}/status", json={"action": "submit"}).status_code == 200 + while client.get(f"/api/years/{year_id}").json()["status"] == "in_review": + assert client.post(f"/api/years/{year_id}/status", json={"action": "approve"}).status_code == 200 + + +def test_reopen_request_needs_reason_and_approver(client, imported): + viewer = user(client, "view@t.local", "viewer", dept=None) + approver = user(client, "ap@t.local", "approver") + approve(client, imported) + y = viewer.get(f"/api/years/{imported}").json() + assert y["actions"]["request_reopen"] and not y["actions"]["reopen"] + assert viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": ""}).status_code == 400 + r = viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Contrato novo de telecomunicações"}) + assert r.status_code == 200 and r.json()["reopened"] is False + assert viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "again please"}).status_code == 409 + y = approver.get(f"/api/years/{imported}").json() + assert y["status"] == "approved" and y["reopen_request"]["requested_by"] == "view" + assert y["actions"]["decide_reopen"] and not viewer.get(f"/api/years/{imported}").json()["actions"]["decide_reopen"] + rid = y["reopen_request"]["id"] + assert viewer.post(f"/api/reopen-requests/{rid}/decide", json={"accept": True}).status_code == 403 + assert approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False}).status_code == 400 # say why + d = approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False, "note": "Já aprovado pela direção"}) + assert d.json()["request"]["status"] == "declined" and d.json()["year"]["status"] == "approved" + # a second request, accepted this time + rid = viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Falta a linha de licenças"}).json()["request"]["id"] + d = approver.post(f"/api/reopen-requests/{rid}/decide", json={"accept": True}).json() + assert d["request"]["status"] == "accepted" and d["year"]["status"] == "draft" + hist = client.get(f"/api/years/{imported}").json()["reopen_requests"] + assert [h["status"] for h in hist] == ["accepted", "declined"] + acts = [r["action"] for r in client.get("/api/activity?entity=year").json()["rows"]] + assert {"reopen_requested", "reopen_declined", "reopen"} <= set(acts) + + +def test_approver_reopens_directly_with_reason(client, imported): + approver = user(client, "ap@t.local", "approver") + approve(client, imported) + r = approver.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Correção de valores"}).json() + assert r["reopened"] is True and r["year"]["status"] == "draft" + + +def test_download_and_reupload_archives_old_version(client, imported): + # downloads keep the name of the file the budget was loaded from + assert 'filename="b.xlsx"' in client.get(f"/api/years/{imported}/workbook.xlsx").headers["content-disposition"] + fresh = client.post("/api/years", json={"year": 2031}).json()["id"] + assert 'filename="Orcamento_IT_2031.xlsx"' in client.get(f"/api/years/{fresh}/workbook.xlsx?lang=pt").headers["content-disposition"] + client.delete(f"/api/years/{fresh}") + r = client.get(f"/api/years/{imported}/workbook.xlsx?lang=pt") + files = {"file": ("Orcamento_IT_2027.xlsx", r.content)} + check = client.post("/api/years/import/check", files=files, data={"department_id": "1"}).json() + assert check["year"] == 2027 and check["existing"]["id"] == imported and check["existing"]["can_replace"] + assert check["existing"]["archived_title"].startswith("Budget 2027 (") + # without confirming, nothing is replaced + assert client.post("/api/years/import", files=files, data={"department_id": "1"}).status_code == 409 + res = client.post("/api/years/import", files=files, data={"department_id": "1", "replace": "true"}).json() + new_id = res["year"]["id"] + assert new_id != imported and res["year"]["title"] == "Budget 2027" and res["year"]["source_file"] == "Orcamento_IT_2027.xlsx" + old = client.get(f"/api/years/{imported}").json() + assert old["archived"] and old["title"].startswith("Budget 2027 (") and old["title"] != "Budget 2027" + assert not any(v for k, v in old["actions"].items() if k != "delete") + line = old["categories"][0]["lines"][0] + assert client.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code in (403, 409) + assert client.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code in (403, 409) + assert [y["id"] for y in client.get("/api/years").json()] == [new_id] + assert [y["id"] for y in client.get("/api/years?archived=true").json()] == [imported] + assert client.get(f"/api/years/{imported}/reports/budget.pdf").status_code == 200 # still viewable + assert abs(old["costs"]["adjusted"] - client.get(f"/api/years/{new_id}").json()["costs"]["adjusted"]) < 0.01 + + # the same file again: the downloaded name is kept, and the previous upload gets the date in its file name + again = client.get(f"/api/years/{new_id}/workbook.xlsx").content + check = client.post("/api/years/import/check", files={"file": ("Orcamento_IT_2027.xlsx", again)}, + data={"department_id": "1"}).json() + assert check["existing"]["same_file"] and check["existing"]["archived_file"].startswith("Orcamento_IT_2027_") + client.post("/api/years/import", files={"file": ("Orcamento_IT_2027.xlsx", again)}, + data={"department_id": "1", "replace": "true"}) + names = [y["source_file"] for y in client.get("/api/years?archived=true").json()] + assert any(n.startswith("Orcamento_IT_2027_2") and n.endswith(".xlsx") for n in names) + + +def test_approved_budget_cannot_be_replaced(client, imported): + data = client.get(f"/api/years/{imported}/workbook.xlsx").content + approve(client, imported) + check = client.post("/api/years/import/check", files={"file": ("b.xlsx", data)}, data={"department_id": "1"}).json() + assert check["existing"]["can_replace"] is False + r = client.post("/api/years/import", files={"file": ("b.xlsx", data)}, data={"department_id": "1", "replace": "true"}) + assert r.status_code == 409 and r.json()["detail"] == "year_not_draft" + + +def test_approver_reopening_answers_pending_request(client, imported): + viewer = user(client, "view@t.local", "viewer", dept=None) + approve(client, imported) + viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Preciso de mudar a linha X"}) + r = client.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Concordo, reabrir"}).json() + assert r["reopened"] and r["request"]["status"] == "accepted" and r["request"]["requested_by"] == "view" + assert len(client.get(f"/api/years/{imported}").json()["reopen_requests"]) == 1 + + +def test_reopen_request_emails_the_approvers(client, imported, fake_mail): + from tests.test_mail import FakeSMTP, configure + configure(client) + user(client, "ap@t.local", "approver") + viewer = user(client, "view@t.local", "viewer", dept=None) + approve(client, imported) + viewer.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Licenças em falta"}) + _, rcpts, msg = FakeSMTP.sent[-1] + assert set(rcpts) == {"admin@test.local", "ap@t.local"} and "Licenças em falta" in msg.get_content() + rid = client.get(f"/api/years/{imported}").json()["reopen_request"]["id"] + client.post(f"/api/reopen-requests/{rid}/decide", json={"accept": False, "note": "Fica para o próximo ano"}) + _, rcpts, msg = FakeSMTP.sent[-1] + assert rcpts == ["view@t.local"] and "Fica para o próximo ano" in msg.get_content() diff --git a/budget-app/backend/tests/test_v6.py b/budget-app/backend/tests/test_v6.py new file mode 100644 index 0000000..57945a9 --- /dev/null +++ b/budget-app/backend/tests/test_v6.py @@ -0,0 +1,42 @@ +from tests.test_mail import configure, fake_mail # noqa: F401 (fixture) +from tests.test_v5 import approve, user + + +def test_upper_management_views_downloads_and_emails_every_department(client, imported, fake_mail): + fb = client.post("/api/departments", json={"name": "F&B", "categories_from": "defaults"}).json() + other = client.post("/api/years", json={"department_id": fb["id"], "year": 2027}).json()["id"] + boss = user(client, "boss@t.local", "management", dept=None) + assert boss.get("/api/auth/me").json()["role"] == "management" + years = boss.get("/api/years").json() + assert {y["department_id"] for y in years} == {1, fb["id"]} + assert [y["id"] for y in boss.get(f"/api/years?department_id={fb['id']}").json()] == [other] + for year_id in (imported, other): + y = boss.get(f"/api/years/{year_id}").json() + assert not any(y["actions"].values()), y["actions"] + assert boss.get(f"/api/years/{year_id}/workbook.xlsx").status_code == 200 + assert boss.get(f"/api/years/{year_id}/reports/cost-report.pdf").status_code == 200 + configure(client) + r = boss.post(f"/api/years/{other}/email", json={"to": ["cfo@example.com"], "reports": [{"kind": "cost-report", "fmt": "pdf"}]}) + assert r.status_code == 200, r.text + + +def test_upper_management_changes_nothing(client, imported): + boss = user(client, "boss@t.local", "management", dept=1) # even with a department + line = client.get(f"/api/years/{imported}").json()["categories"][0]["lines"][0] + assert boss.put(f"/api/lines/{line['id']}", json={"name": "x"}).status_code == 403 + assert boss.post("/api/years", json={"year": 2032}).status_code == 403 + assert boss.post(f"/api/years/{imported}/status", json={"action": "submit"}).status_code == 403 + assert boss.delete(f"/api/years/{imported}").status_code == 403 + assert boss.post("/api/categories", json={"department_id": 1, "name_en": "X"}).status_code == 403 + assert boss.get("/api/users").status_code == 403 and boss.get("/api/activity").status_code == 403 + # never an approver, even when named in a workflow step + me = boss.get("/api/auth/me").json()["id"] + client.put("/api/departments/1/workflow", json={"steps": [{"name": "Board", "approver_mode": "users", "approver_ids": [me]}]}) + y = boss.get(f"/api/years/{imported}").json() + assert not y["actions"]["approve"] + # and does not ask for reopening: that is for the people who work on budgets + client.put("/api/departments/1/workflow", json={"steps": []}) + approve(client, imported) + y = boss.get(f"/api/years/{imported}").json() + assert y["status"] == "approved" and not y["actions"]["request_reopen"] + assert boss.post(f"/api/years/{imported}/reopen-requests", json={"reason": "Quero rever"}).status_code == 403 diff --git a/budget-app/backend/tests/test_v7.py b/budget-app/backend/tests/test_v7.py new file mode 100644 index 0000000..4183a01 --- /dev/null +++ b/budget-app/backend/tests/test_v7.py @@ -0,0 +1,105 @@ +import time +from io import BytesIO + +from fastapi.testclient import TestClient +from PIL import Image + +from app import mfa +from app.main import app +from tests.test_v5 import H, user + + +def login(email, password, code=None): + c = TestClient(app) + c.headers.update(H) + body = {"email": email, "password": password} + if code is not None: + body["code"] = code + return c, c.post("/api/auth/login", json=body) + + +def now_code(secret, offset=0): + return mfa.code_at(secret, int(time.time() // mfa.STEP) + offset) + + +def test_rfc6238_vectors(): + import base64 + s = base64.b32encode(b"12345678901234567890").decode().rstrip("=") + assert mfa.code_at(s, 59 // 30) == "287082" and mfa.code_at(s, 1111111109 // 30) == "081804" + + +def test_mfa_set_up_sign_in_recovery_and_reset(client): + ana = user(client, "ana@t.local", "editor") + setup = ana.post("/api/auth/mfa/setup").json() + assert setup["uri"].startswith("otpauth://totp/") and " "$file.tmp"; then + mv "$file.tmp" "$file" + echo "$(date -Iseconds) backup written: $file" + find /backups -name 'budget_*.sql.gz' -mtime +"$BACKUP_KEEP_DAYS" -delete + return 0 + fi + rm -f "$file.tmp" + echo "$(date -Iseconds) backup FAILED" >&2 + return 1 +} + +answer_requests() { + for request in /requests/*.request; do + [ -e "$request" ] || continue + id=$(basename "$request" .request) + echo "$(date -Iseconds) backup requested: $(cat "$request" 2>/dev/null)" + if dump _before_upgrade; then echo ok > "/requests/$id.done"; else echo failed > "/requests/$id.done"; fi + rm -f "$request" + done + # answers nobody picked up + find /requests -name '*.done' -mmin +60 -delete 2>/dev/null || true +} + +until pg_isready -q; do sleep 2; done +dump || true +touch /tmp/first-backup-done +while true; do + now=$(date +%s) + next=$(date -d "$(date +%Y-%m-%d) $BACKUP_TIME" +%s 2>/dev/null || date -D '%Y-%m-%d %H:%M' -d "$(date +%Y-%m-%d) $BACKUP_TIME" +%s) + [ "$next" -le "$now" ] && next=$((next + 86400)) + while [ "$(date +%s)" -lt "$next" ]; do + answer_requests + sleep 5 + done + dump || true +done diff --git a/budget-app/backup/restore.sh b/budget-app/backup/restore.sh new file mode 100755 index 0000000..791b0d9 --- /dev/null +++ b/budget-app/backup/restore.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Usage (from the project folder): docker compose exec backup restore.sh /backups/budget_YYYYMMDD_HHMMSS.sql.gz +set -eu +file="${1:?give the backup file, e.g. /backups/budget_20270101_023000.sql.gz}" +echo "Restoring $file into database $PGDATABASE ..." +gunzip -c "$file" | psql -q -v ON_ERROR_STOP=1 +echo "Done. Restart the api container: docker compose restart api" diff --git a/budget-app/docker-compose.yml b/budget-app/docker-compose.yml new file mode 100644 index 0000000..cc2b6d9 --- /dev/null +++ b/budget-app/docker-compose.yml @@ -0,0 +1,86 @@ +name: budget-capex + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: budget + POSTGRES_USER: budget + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + TZ: ${TZ:-Europe/Lisbon} + volumes: + - db-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U budget -d budget"] + interval: 10s + timeout: 5s + retries: 10 + + api: + build: ./backend + image: budget-capex-api:latest + restart: unless-stopped + depends_on: + db: + condition: service_healthy + backup: + condition: service_healthy + environment: + DATABASE_URL: postgresql+psycopg://budget:${POSTGRES_PASSWORD}@db:5432/budget + SECRET_KEY: ${SECRET_KEY:?set SECRET_KEY in .env} + ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env} + ADMIN_NAME: ${ADMIN_NAME:-Administrator} + DEFAULT_LANGUAGE: ${DEFAULT_LANGUAGE:-pt} + COOKIE_SECURE: ${COOKIE_SECURE:-false} + SESSION_HOURS: ${SESSION_HOURS:-12} + TZ: ${TZ:-Europe/Lisbon} + volumes: + # the api asks the backup container for a fresh backup here before every database upgrade + - backup-requests:/requests + + web: + build: ./frontend + image: budget-capex-web:latest + restart: unless-stopped + depends_on: + - api + environment: + SITE_ADDRESS: ${SITE_ADDRESS:-:80} + ports: + - "${HTTP_PORT:-8080}:80" + - "${HTTPS_PORT:-8443}:443" + volumes: + - caddy-data:/data + + backup: + image: postgres:16-alpine + restart: unless-stopped + depends_on: + db: + condition: service_healthy + entrypoint: ["/bin/sh", "/scripts/backup.sh"] + healthcheck: + test: ["CMD", "test", "-f", "/tmp/first-backup-done"] + interval: 5s + timeout: 3s + retries: 120 + environment: + PGHOST: db + PGUSER: budget + PGDATABASE: budget + PGPASSWORD: ${POSTGRES_PASSWORD} + BACKUP_TIME: ${BACKUP_TIME:-02:30} + BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-30} + TZ: ${TZ:-Europe/Lisbon} + volumes: + - ./backup/backup.sh:/scripts/backup.sh:ro + - ./backup/restore.sh:/usr/local/bin/restore.sh:ro + - ${BACKUP_DIR:-./backups}:/backups + - backup-requests:/requests + +volumes: + db-data: + caddy-data: + backup-requests: diff --git a/budget-app/docs/guide.html b/budget-app/docs/guide.html new file mode 100644 index 0000000..8230362 --- /dev/null +++ b/budget-app/docs/guide.html @@ -0,0 +1,815 @@ + + + + + +Budget & Capex app guide + + + +

+ +

Budget & Capex: how the app works

+

Version 7.0 · a guide to what the app does, who can do what, how a budget moves from draft to approved, and how each field you change affects the numbers in the reports.

+

The screens are in Portuguese or English (PT/EN switch at the top). This guide uses the English names, with the Portuguese name in italics where it helps.

+ + + + +

1. The app at a glance

+

Each department prepares one budget per year. A budget is a list of cost and revenue lines grouped by category. Each line has last year's actual amount, this year's base amount and an adjustment, and it is split over the months. From those lines the app produces two documents, on screen and in PDF and Excel: the Capex and Costs report (Capex e Custos) and the Monthly budget (Orçamento mensal).

+ +
+ + Pages of the app and who sees them + + + + EVERY USER + + Budgets (Orçamentos) + List per department and year + New year · Import Excel · Excel template + Archived versions + → open a budget to edit, approve, + download, email, request reopening + + + Budget page + Lines by category, totals, year rate + Workflow steps and status + Download ▾ · Send by email · Upload Excel + History (changes and reopen requests) + Delete (administrators only) + + + Reports (Relatórios) + Capex and Costs · Monthly budget + Choose language and site + View online · PDF · Excel + Send by email + My profile: picture, MFA, theme + + + + + APPROVERS + + Categories (Categorias) + Their own department's categories + + + Upper Management: read-only + Budgets + Reports only, all departments + + ADMINISTRATORS + + Departments + and their workflows + + Categories and sites + all departments + + Users + role, department, MFA on/off + + Activity (Atividade) + who did what, filters, CSV + + Email + sending account · mail log + +
Pages in the top menu. Upper Management sees only Budgets and Reports, for every department, and can view, download and email but not change anything. Approvers also see everything every user sees, and administrators see everything.
+
+ + +

2. What runs on the server

+

The app runs on one server on your network with Docker. People open it in a browser at http://<server>:8080. Nothing needs to be installed on the PCs.

+ +
+ + Containers and how they connect + + + + + Browsers + PCs on the network + + + SERVER · DOCKER COMPOSE "budget-capex" + + + web (Caddy) + screens, HTTPS, + passes /api on + + + api (Python) + rules, calculations, + PDF and Excel + + + db + PostgreSQL: + all the data + + + backup + nightly dump + one before every upgrade + + + Mail server + SMTP: send + IMAP: Sent copy + + + backups/ + folder on disk + + + :8080 + + + + emails + + + + "back up first" + +
Four containers. Your data lives in the db container's volume, not in the app folder, so replacing the folder with a new version keeps it. The dashed arrow is the request the api makes before it changes the database during an upgrade.
+
+ + +

3. What is stored

+
+ + Main records and how they relate + + + + + Department + e.g. IT, F&B · name, active + + + User + role, department, picture + MFA, theme, language + + + Workflow step + ordered; approved by the + dept's approvers or named users + + + Category + PT and EN name, cost/revenue, + order, show when empty + + + Budget (year) + year, title, year rate, status, + current step, source file + + + Line + category, site, prior actual, + base, adjustment, months, + remarks + + + Site + Sagres, Quinta, Chiado, Oriente + + + Reopen request + reason, status, decision + + + Shared by the whole app + Activity log: every action by every user + Mail settings: the sending account (passwords encrypted) + Mail log: every email sent and whether it arrived + Archived versions: replaced budgets, read-only + Sites are shared by all departments + + + + + + + many + + + + many + +
Solid arrows: "has many". Dashed: a line points to one category and optionally one site. A department's categories are its own, so an IT budget never shows F&B lines.
+
+ + +

4. Roles and permissions

+

Every user has one role and belongs to one department. Everyone can see and export every budget, and switch between departments with the department list on the Budgets page. Only the budget's own department can change or approve it.

+
Upper Management (Gestão de Topo, new in version 6) is a read-only role for directors. These users see every department and start on All departments. They can open any budget and its reports, download them (the budget file, PDF and Excel) and send them by email. They cannot create, import, edit, upload, submit, approve, reopen, request reopening or delete, they never count as approvers, and their menu shows only Budgets and Reports. A department is optional for them.
+
+ + + + + + + + + + + + + + + + +
WhatViewerUpper MgmtEditorApproverAdmin
View budgets and reports, download PDF / Excelallallallallall
Send reports by email (once email is set up)yesyesyesyesyes
Request reopening of an approved budget (with reason)yesnoyesyesyes
Create, import, edit a draft budget; upload a changed filenonoown deptown deptany
Submit a draft for reviewnonoown deptown deptany
Approve or return a workflow stepnononoown dept*own dept*
Reopen directly, accept or decline reopen requestsnononoown deptany
Manage categoriesnononoown deptany
Delete a budget (any status, typed confirmation)nonononoyes
Own profile: picture, light/dark mode, language, password, turn own MFA on or offyesyesyesyesyes
See who has MFA on; reset another user's MFAnonononoyes
Departments, workflows, users, sites, email settings, activity and mail logsnonononoyes
+
+

* A workflow step can instead name specific users who approve it; then only those users can approve that step, whatever their role (Upper Management users can never be named). An administrator can always return a budget in review to draft.

+ + +

5. A budget's life cycle

+
+ + Budget statuses and the actions that move between them + + + + + + Create: New year (empty or copied from an earlier year) or Import Excel + + + + Draft + editable by the + department + + + In review + + Step 1 + e.g. IT director + + Step 2 + e.g. Finance + … n + + + + + Approved + locked; view, export + and email only + + + Submit + + Approve + (last step) + + + Return to draft (approver of the step, or admin) + + + Reopen: approver/admin with a reason, or an accepted reopen request + + + Archived 🔒 + replaced by an upload; read-only + + Upload a file for + the same year + + + Delete: administrators only, any status + +
Only a draft can be changed. Each approval moves the budget to the next workflow step; the last approval makes it Approved. A department with no steps configured is approved in one step by its approvers.
+
+ + +

6. How the numbers are calculated

+

Every total in the app and in the reports is built from the lines, in this order. Change any input on the left, and everything to its right follows.

+ +
+ + From a line's fields to the report totals + + + + YOU TYPE + THE APP WORKS OUT + YOU SEE + + + Prior-year actual + number or 12*839,67 + + + Base (without adjustment) + number or calculation + + + Adjustment + year rate · % · factor · none + + + Year rate (budget) + Inflation + adjustments % + + + Months + 12 · chosen months · typed + + + Factor + e.g. 1,055 or 1,15 + + + Adjusted amount + base × factor, to cents + + + Change € and % + adjusted − prior + + + 12 monthly amounts + in cents, add up exactly + + + Category totals + sum of its lines + (filtered by site if chosen) + + + Budget totals + costs and revenue, + prior → adjusted, change + + + Capex and Costs report + year totals and charts + + + Monthly budget + Jan … Dec per line + + + + only for "year rate" + + + + + + + + + + + + +
With Monthly amounts (typed months) the path is different: the year total is simply the sum of the twelve months, and Base and Adjustment are not used.
+
+ +

The adjustment and the factor

+
+ + + + + + + + +
Adjustment (Ajuste)Factor usedExample, base 96 532,00 €
Year rate (Taxa do ano)1 + the budget's year raterate 5,5 % → × 1,055 = 101 841,26 €
Percent (%)1 + adjustment value ÷ 100value 15 → × 1,15 = 111 011,80 €
Factor (Fator ×)the adjustment valuevalue 2,055 (hardware price rise) → 198 373,26 €
None (Sem ajuste)196 532,00 €
+
+ +
+Worked example (IT 2027, "Mobile phone subscriptions") +
Prior-year actual 96 532,00 € +Base 96 532,00 € +Adjustment Factor × 1,15 +Adjusted 96 532,00 × 1,15 = 111 011,80 € +Change 111 011,80 − 96 532,00 = +14 479,80 € (+15,00 %) +Months chosen: Jan–Apr → 27 752,95 € each, May–Dec 0
+
+ +

Rounding rules

+
    +
  • Each line's adjusted amount is rounded to cents before it is added up, as the finance reports do. Totals are therefore exact sums of the lines you see.
  • +
  • Monthly amounts are in cents. The rounding difference goes to the last month (December, or the last chosen month), so the twelve months always add up to the adjusted amount.
  • +
  • Change % is blank when the prior-year actual is zero.
  • +
+ +

Typing amounts

+

Amount fields work like Excel: 12*839,67+3292,56 or =5512*12. Both 1 234,56 and 1,234.56 are understood. The calculation is kept, so you can see later how a figure was built.

+ + +

7. Every field and what it changes

+ +

On a budget

+
+ + + + + + + + +
FieldWhat it doesWhat changes when you edit it
YearThe budget year; one current budget per department and year.Column headings ("2026 actual", "2027 adjusted"), report titles, file names.
TitleName shown in lists and reports.Budget list, report headers, email subject suggestion.
Inflation + adjustments (%) (Taxa do ano)The year rate.Every line whose adjustment is "Year rate": their factor, adjusted amount, months, and so all category and budget totals and both reports. Lines with %, factor or none are not affected.
Copy from an earlier year (when creating)Starts the new year from another year's lines.Each copied line's prior actual and base both start at last year's adjusted amount; adjustment type, months and site are kept. Replace the actual with the real figure.
+
+ +

On a line

+
+ + + + + + + + + + + + + +
FieldWhat it doesWhat changes when you edit it
Line (name) and DescriptionLabels.Text in the budget and the reports only.
CategoryWhich group the line belongs to (your department's categories).Moves the amounts from one category total to another: category pages and charts in Capex and Costs, grouping in the Monthly budget. Budget totals stay the same, unless the category is a revenue category, in which case the amount counts as revenue instead of cost.
Site (Unidade)Sagres, Quinta, Chiado, Oriente, or Group.What appears when a page or report is filtered by site. "Group" lines appear only in the all-sites view.
Prior-year actualWhat was spent last year.The "prior" column, change € and change %, category and budget prior totals, and the comparison charts. Does not change the new year's amount.
Base (sem ajuste)The new year's amount before the adjustment.Adjusted amount → months → every total for the new year → both reports.
Adjustment and its valueHow the base becomes the adjusted amount (see the table above).Factor → adjusted amount → months → totals → reports.
Reason for adjustmentWhy it went up or down.Shown under the line and in the reports.
MonthsHow the year amount is spread: split over 12, split over chosen months, or typed monthly amounts.Only the Monthly budget's month columns and the monthly totals. The year total does not change (except with typed amounts, where the year total is the sum of the months).
RemarksFree notes.Shown on the line; no effect on numbers.
+
+ +

On a category

+
+ + + + + + + + + +
FieldWhat changes
Name (PT / EN)The heading in the language of the screen or the report.
Section: cost or revenueWhether its lines count in the cost totals or the revenue totals.
OrderThe order of categories on screen and in the reports.
Show when emptyWhether a category with no lines still appears (with zero) in the budget and reports.
ActiveInactive categories can't be chosen for new lines. Existing lines keep them. A category with lines can be deactivated, but not deleted.
+
+ + +

8. Splitting a year into months

+
+ + The three ways to split a line into months + Split over 12 (Dividir por 12) · 12 000,00 € + + + JanFebMarAprMayJunJulAugSepOctNovDec + 1 0001 0001 0001 0001 0001 0001 0001 0001 0001 0001 0001 000 + + + Split over chosen months (Dividir pelos meses escolhidos) · Jan–Apr ticked + + + JanFebMarAprMayJunJulAugSepOctNovDec + 3 0003 0003 0003 000 + 00000000 + + + Monthly amounts (Valores mensais) · each month typed; the year total is their sum + + + JanFebMarAprMayJunJulAugSepOctNovDec + 5005002 000800800800001 6008008003 400 + + +
The first two keep the year total (base × adjustment) and only decide when it is spent. Quick buttons on the line pick a quarter (Q1–Q4) or a half-year (H1, H2). Switching a line from typed months to a split keeps its year total as the base.
+
+ + +

9. Reports, downloads and email

+
+
+

Capex and Costs Capex e Custos

+
    +
  • Totals: prior-year actual vs adjusted, change € and %
  • +
  • Chart of each category, last year vs this year
  • +
  • Chart of the change by category
  • +
  • One page per category with its lines and reasons
  • +
  • Summary table
  • +
+
+
+

Monthly budget Orçamento mensal

+
    +
  • Every line with its twelve months and year total
  • +
  • Category subtotals and monthly totals
  • +
  • Option: include empty categories
  • +
  • Excel version keeps live formulas
  • +
+
+
+

On the Reports page you pick the budget, the language (Portuguese or English) and the site; the same choice applies to viewing online, PDF, Excel and email. On a budget page, Download ▾ offers the editable budget file and both reports, and Send by email lets you attach any of them. That works in every status, including draft and approved.

+

Sending by email asks for recipients (To, Cc, "send me a copy"), a subject and a message, and which files to attach. The email goes out from the account the administrator set up, a copy is saved in that mailbox's Sent folder, and the mail log records it.

+ + +

10. Excel: template, download, re-upload

+
+ + Download, change and upload a budget file + + + + + Draft budget + IT 2027 "Budget 2027" + in the app + + + Download ▾ + Budget file + Orcamento_IT_2027.xlsx + + + Change it in Excel + lines, amounts, months + same file name + + + Upload Excel + pop-up: "The file + already exists" · OK + + + + + + + New current budget + the lines from your file; same title "Budget 2027" + "Loaded from Orcamento_IT_2027.xlsx" · editable draft + + + Old version, archived 🔒 + renamed "Budget 2027 (2026-10-28 23:00)" + Orcamento_IT_2027_2026-10-28_2300.xlsx + locked: view, export, email; admin may delete + + + + +
Nothing is lost: the version you replace is kept, renamed with the date and time, and locked. Only a draft can be replaced. An approved or in-review budget must be reopened first. Archived versions are listed under Budgets → "Show archived versions".
+
+

Excel template (Budgets → Modelo Excel) gives a workbook for a department and year, blank or pre-filled from an existing budget, in PT or EN. It has an Instructions sheet and a Lines sheet with drop-down lists for category, site and adjustment, twelve month columns, and a last column Split over months where you can write 1-4 or Jan, Fev, Mar, Abr. Unknown categories are created on import; anything skipped is listed. The original "Budget para 2027" workbook layout can also be imported.

+ + +

11. Reopening an approved budget

+
+ + Reopen request flow + + + + + Approved budget + any user but Upper Mgmt + clicks "Request reopening" + + + Reason (mandatory) + at least 5 characters; + request is pending + + + Approvers + admins + see it on the budget, + get an email + + + Accept and reopen + budget back to draft; + requester emailed + + + Decline + reason required; stays + approved; requester emailed + + + + + + +
An approver or administrator of the department can also reopen directly with Reopen, giving a reason; that also answers an open request. One request can be pending at a time. All requests, decisions and reasons stay in the budget's History.
+
+ + +

12. Admin: email, logs, backups, upgrades

+
+
+

Email → Sending account

+
    +
  • SMTP server, port, security (STARTTLS 587 / SSL 465 / none), username and password
  • +
  • Sender address and name, reply-to, signature
  • +
  • Copy in the Sent folder over IMAP (found automatically, or named)
  • +
  • Test connection signs in only; Send test email sends one
  • +
  • Turning email on adds "Send by email" for everyone, Upper Management included
  • +
+

Gmail already keeps its own copy, so turn the Sent-folder option off there.

+
+
+

Logs

+
    +
  • Activity: sign-ins (and failed ones), every change to budgets and lines, imports, approvals, reopen requests, archiving, deletions, report views, downloads and emails, admin changes. Filter and export to CSV.
  • +
  • Email → Mail log: who sent what to whom, attachments, delivered or failed, Sent-folder copy saved or not. Export to CSV.
  • +
+
+
+

Backups

+
    +
  • A database dump every night at BACKUP_TIME into backups/, kept BACKUP_KEEP_DAYS days
  • +
  • An extra one, …_before_upgrade.sql.gz, before every upgrade that changes the database
  • +
  • Restore: docker compose exec backup restore.sh /backups/<file>
  • +
  • Copy backups/ to another disk or NAS regularly
  • +
+
+
+

Upgrading to a new version

+
    +
  1. Unzip the new version over the app folder, keeping .env and backups/
  2. +
  3. Run docker compose up -d --build
  4. +
+

The api asks for a backup, waits for it, then updates the database. If the backup fails, it does not upgrade. Never run docker compose down -v: -v deletes the data.

+
+
+ + +

13. Signing in, MFA, appearance and picture

+

MFA (two-step sign-in, autenticação em dois passos) is optional and each user turns it on for themselves. With MFA on, someone who learns a password still cannot sign in without the user's phone.

+
+ + Sign-in with and without MFA + + + + + + + Email + password + wrong: "invalid + credentials", logged + + + MFA on? + set per user in + My profile + + + Verification code + 6 digits from the app, changes + every 30 s, works only once; + or a recovery code (xxxx-xxxx) + + + 5 wrong codes in a row + code sign-in locked for 5 minutes + + + Signed in + session lasts + SESSION_HOURS + + + + yes + + no MFA: straight in + + right + + wrong + +
The code step appears only for users who turned MFA on. A code is accepted if it is the current one or the one just before or after, so a phone clock that is slightly off still works.
+
+ +
+
+

Turning MFA on (My profile)

+
    +
  1. Install an authenticator app on the phone: Microsoft Authenticator, Google Authenticator, Authy, 1Password…
  2. +
  3. In My profile, press Turn on MFA and scan the QR code (or type the key shown).
  4. +
  5. Type the 6-digit code the app shows and press Confirm and turn on. MFA is only on after this step.
  6. +
  7. Save the 8 recovery codes. Each one works once, for when the phone is not at hand. They are not shown again; New recovery codes replaces them.
  8. +
+

Turning MFA off needs the password and a code.

+
+
+

For administrators

+
    +
  • Users has an MFA column: On or Off for each user.
  • +
  • Someone lost their phone and recovery codes: Users → Edit → Reset MFA. They sign in with the password and can turn MFA on again.
  • +
  • MFA secrets are stored encrypted with SECRET_KEY. Keep the same .env when upgrading, or everyone's MFA has to be reset.
  • +
  • The Activity log records MFA turned on or off, resets, wrong codes and recovery codes used.
  • +
+
+
+

Light and dark mode

+

The round button in the top bar (and on the sign-in page) cycles Automatic → Light → Dark. Automatic follows the computer's setting. The same choice is in My profile → Appearance, and it is saved with the user, so it follows them to other computers. Reports in PDF and Excel are not affected.

+
+
+

Profile picture

+

In My profile, Choose picture takes a JPG, PNG, GIF or WebP of up to 5 MB. It is cropped to a square from the centre and reduced to 256 px. It shows in the top bar and in the users list; without one, the user's initials are shown. Pictures are stored in the database, so they are in the backups. Remove deletes it.

+
+
+ +

Budget & Capex · version 7.0.0 · guide updated 29 October 2026.

+
+ + diff --git a/budget-app/frontend/.dockerignore b/budget-app/frontend/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/budget-app/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/budget-app/frontend/Caddyfile b/budget-app/frontend/Caddyfile new file mode 100644 index 0000000..1eee0d7 --- /dev/null +++ b/budget-app/frontend/Caddyfile @@ -0,0 +1,32 @@ +{ + admin off +} + +{$SITE_ADDRESS::80} { + encode zstd gzip + + handle /api/* { + reverse_proxy api:8000 { + transport http { + read_timeout 120s + } + } + } + + handle { + root * /srv/web + try_files {path} /index.html + file_server + } + + header { + X-Content-Type-Options nosniff + Referrer-Policy same-origin + X-Frame-Options SAMEORIGIN + -Server + } + + request_body { + max_size 25MB + } +} diff --git a/budget-app/frontend/Dockerfile b/budget-app/frontend/Dockerfile new file mode 100644 index 0000000..6c10e57 --- /dev/null +++ b/budget-app/frontend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:22-alpine AS build +WORKDIR /web +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund +COPY index.html vite.config.js ./ +COPY src ./src +RUN npm run build + +FROM caddy:2-alpine +COPY Caddyfile /etc/caddy/Caddyfile +COPY --from=build /web/dist /srv/web diff --git a/budget-app/frontend/index.html b/budget-app/frontend/index.html new file mode 100644 index 0000000..723b6bb --- /dev/null +++ b/budget-app/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Budget & Capex + + +
+ + + diff --git a/budget-app/frontend/package-lock.json b/budget-app/frontend/package-lock.json new file mode 100644 index 0000000..5f87679 --- /dev/null +++ b/budget-app/frontend/package-lock.json @@ -0,0 +1,1732 @@ +{ + "name": "budget-capex-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "budget-capex-web", + "version": "1.0.0", + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "6.28.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "4.3.4", + "vite": "5.4.11" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", + "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.66.0.tgz", + "integrity": "sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.66.0.tgz", + "integrity": "sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.66.0.tgz", + "integrity": "sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.66.0.tgz", + "integrity": "sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.66.0.tgz", + "integrity": "sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.66.0.tgz", + "integrity": "sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.66.0.tgz", + "integrity": "sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.66.0.tgz", + "integrity": "sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.66.0.tgz", + "integrity": "sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.66.0.tgz", + "integrity": "sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.66.0.tgz", + "integrity": "sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.66.0.tgz", + "integrity": "sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.66.0.tgz", + "integrity": "sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.66.0.tgz", + "integrity": "sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.66.0.tgz", + "integrity": "sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.66.0.tgz", + "integrity": "sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.66.0.tgz", + "integrity": "sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.66.0.tgz", + "integrity": "sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.66.0.tgz", + "integrity": "sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.66.0.tgz", + "integrity": "sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.66.0.tgz", + "integrity": "sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.66.0.tgz", + "integrity": "sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.66.0.tgz", + "integrity": "sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.66.0.tgz", + "integrity": "sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.66.0.tgz", + "integrity": "sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.4.tgz", + "integrity": "sha512-SCCPBJtYLdE8PX/7ZQAs1QAZ8Jqwih+0VBLum1EGqmCCQal+MIUqLCzj3ZUy8ufbC0cAM4LRlSTm7IQJwWT4ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.26.0", + "@babel/plugin-transform-react-jsx-self": "^7.25.9", + "@babel/plugin-transform-react-jsx-source": "^7.25.9", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.467", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.467.tgz", + "integrity": "sha512-Nw9X371rtZmYcBSnEijnCDONILjs93biCZO34juC7MBOmK8E+GFnyD4T/WvyrlJkppul7oxg6TwvtKst9SODTw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz", + "integrity": "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.21.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz", + "integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.21.0", + "react-router": "6.28.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.66.0.tgz", + "integrity": "sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.66.0", + "@rollup/rollup-android-arm64": "4.66.0", + "@rollup/rollup-darwin-arm64": "4.66.0", + "@rollup/rollup-darwin-x64": "4.66.0", + "@rollup/rollup-freebsd-arm64": "4.66.0", + "@rollup/rollup-freebsd-x64": "4.66.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.66.0", + "@rollup/rollup-linux-arm-musleabihf": "4.66.0", + "@rollup/rollup-linux-arm64-gnu": "4.66.0", + "@rollup/rollup-linux-arm64-musl": "4.66.0", + "@rollup/rollup-linux-loong64-gnu": "4.66.0", + "@rollup/rollup-linux-loong64-musl": "4.66.0", + "@rollup/rollup-linux-ppc64-gnu": "4.66.0", + "@rollup/rollup-linux-ppc64-musl": "4.66.0", + "@rollup/rollup-linux-riscv64-gnu": "4.66.0", + "@rollup/rollup-linux-riscv64-musl": "4.66.0", + "@rollup/rollup-linux-s390x-gnu": "4.66.0", + "@rollup/rollup-linux-x64-gnu": "4.66.0", + "@rollup/rollup-linux-x64-musl": "4.66.0", + "@rollup/rollup-openbsd-x64": "4.66.0", + "@rollup/rollup-openharmony-arm64": "4.66.0", + "@rollup/rollup-win32-arm64-msvc": "4.66.0", + "@rollup/rollup-win32-ia32-msvc": "4.66.0", + "@rollup/rollup-win32-x64-gnu": "4.66.0", + "@rollup/rollup-win32-x64-msvc": "4.66.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/budget-app/frontend/package.json b/budget-app/frontend/package.json new file mode 100644 index 0000000..4a9f9e4 --- /dev/null +++ b/budget-app/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "budget-capex-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "18.3.1", + "react-dom": "18.3.1", + "react-router-dom": "6.28.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "4.3.4", + "vite": "5.4.11" + } +} diff --git a/budget-app/frontend/src/App.jsx b/budget-app/frontend/src/App.jsx new file mode 100644 index 0000000..f142922 --- /dev/null +++ b/budget-app/frontend/src/App.jsx @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Navigate, Route, Routes } from 'react-router-dom' +import { api } from './api.js' +import { AuthContext, atLeast } from './auth.js' +import { I18nContext, makeT } from './i18n.js' +import Layout from './components/Layout.jsx' +import Login from './pages/Login.jsx' +import Years from './pages/Years.jsx' +import YearEditor from './pages/YearEditor.jsx' +import Reports from './pages/Reports.jsx' +import Users from './pages/Users.jsx' +import Settings from './pages/Settings.jsx' +import Profile from './pages/Profile.jsx' +import Departments from './pages/Departments.jsx' +import Activity from './pages/Activity.jsx' +import Mail from './pages/Mail.jsx' + +const LANG_KEY = 'budget.lang' +const THEME_KEY = 'budget.theme' +const THEMES = ['system', 'light', 'dark'] + +function storedLang() { + try { + return localStorage.getItem(LANG_KEY) || 'pt' + } catch { + return 'pt' + } +} + +function storedTheme() { + try { + return localStorage.getItem(THEME_KEY) || 'system' + } catch { + return 'system' + } +} + +export default function App() { + const [user, setUser] = useState(undefined) + const [lang, setLangState] = useState(storedLang) + const [theme, setThemeState] = useState(storedTheme) + + useEffect(() => { + api.get('/api/auth/me').then((u) => { + setUser(u) + setLangState(u.language) + if (THEMES.includes(u.theme)) setThemeState(u.theme) + }).catch(() => setUser(null)) + const onLogout = () => setUser(null) + window.addEventListener('budget:logout', onLogout) + return () => window.removeEventListener('budget:logout', onLogout) + }, []) + + useEffect(() => { + document.documentElement.lang = lang + try { + localStorage.setItem(LANG_KEY, lang) + } catch { /* private mode */ } + }, [lang]) + + // system: follow the computer; light / dark: always + useEffect(() => { + const root = document.documentElement + if (theme === 'system') delete root.dataset.theme + else root.dataset.theme = theme + try { + localStorage.setItem(THEME_KEY, theme) + } catch { /* private mode */ } + }, [theme]) + + const setTheme = useCallback((next) => { + setThemeState(next) + if (user) { + setUser((u) => u && { ...u, theme: next }) + api.put('/api/auth/me', { theme: next }).catch(() => {}) + } + }, [user]) + + const setLang = useCallback((next) => { + setLangState(next) + if (user) api.put('/api/auth/me', { language: next }).catch(() => {}) + }, [user]) + + const t = useMemo(() => makeT(lang), [lang]) + const auth = useMemo(() => ({ user, setUser, lang, setLang, theme, setTheme }), [user, lang, setLang, theme, setTheme]) + + if (user === undefined) return null + + return ( + + + {!user ? ( + { setUser(u); setLangState(u.language); if (THEMES.includes(u.theme)) setThemeState(u.theme) }} /> + ) : ( + + + } /> + } /> + } /> + } /> + {atLeast(user, 'admin') && } />} + {atLeast(user, 'approver') && } />} + {atLeast(user, 'admin') && } />} + {atLeast(user, 'admin') && } />} + {atLeast(user, 'admin') && } />} + } /> + + + )} + + + ) +} diff --git a/budget-app/frontend/src/api.js b/budget-app/frontend/src/api.js new file mode 100644 index 0000000..8f95df5 --- /dev/null +++ b/budget-app/frontend/src/api.js @@ -0,0 +1,34 @@ +export class ApiError extends Error { + constructor(status, code) { + super(code) + this.status = status + this.code = code + } +} + +async function request(method, url, body) { + const opts = { method, credentials: 'same-origin', headers: { 'X-Requested-With': 'budget-app' } } + if (body instanceof FormData) { + opts.body = body + } else if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json' + opts.body = JSON.stringify(body) + } + const res = await fetch(url, opts) + if (res.status === 401 && !url.endsWith('/auth/login')) { + window.dispatchEvent(new Event('budget:logout')) + } + const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null + if (!res.ok) { + const detail = data?.detail + throw new ApiError(res.status, typeof detail === 'string' ? detail : JSON.stringify(detail)) + } + return data +} + +export const api = { + get: (url) => request('GET', url), + post: (url, body) => request('POST', url, body ?? {}), + put: (url, body) => request('PUT', url, body), + del: (url) => request('DELETE', url), +} diff --git a/budget-app/frontend/src/auth.js b/budget-app/frontend/src/auth.js new file mode 100644 index 0000000..5fa3d6e --- /dev/null +++ b/budget-app/frontend/src/auth.js @@ -0,0 +1,9 @@ +import { createContext, useContext } from 'react' + +// same order as the server: Upper Management ('management') only views, downloads and emails +export const ROLES = ['viewer', 'management', 'editor', 'approver', 'admin'] +export const AuthContext = createContext(null) +export const useAuth = () => useContext(AuthContext) +export const atLeast = (user, role) => !!user && ROLES.indexOf(user.role) >= ROLES.indexOf(role) + +export const THEMES = ['system', 'light', 'dark'] diff --git a/budget-app/frontend/src/components/EmailDialog.jsx b/budget-app/frontend/src/components/EmailDialog.jsx new file mode 100644 index 0000000..999a151 --- /dev/null +++ b/budget-app/frontend/src/components/EmailDialog.jsx @@ -0,0 +1,68 @@ +import { useState } from 'react' +import { api } from '../api.js' +import { useAuth } from '../auth.js' +import { useT } from '../i18n.js' +import { ErrorNote, Field, Modal } from './ui.jsx' +import { SendResult } from '../pages/Mail.jsx' + +const CHOICES = [['cost-report', 'pdf'], ['cost-report', 'xlsx'], ['budget', 'pdf'], ['budget', 'xlsx'], ['workbook', 'xlsx']] + +// Sends the chosen reports of one budget, with the language and site picked on the Reports page. +export default function EmailDialog({ year, initial, reportLang, siteId, siteName, includeEmpty, onClose }) { + const t = useT() + const { user } = useAuth() + const [to, setTo] = useState('') + const [cc, setCc] = useState('') + const [copyMe, setCopyMe] = useState(false) + const [subject, setSubject] = useState(`${year.department} ${year.year} — ${year.title}`) + const [message, setMessage] = useState(t('email_default_message', { title: year.title, dept: year.department, name: user.name })) + const [picked, setPicked] = useState(() => new Set(initial ? [initial.join('.')] : ['cost-report.pdf'])) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [done, setDone] = useState(null) + + const toggle = (key) => { + const next = new Set(picked) + next.has(key) ? next.delete(key) : next.add(key) + setPicked(next) + } + const send = async () => { + setBusy(true); setError(null) + try { + const log = await api.post(`/api/years/${year.id}/email`, { + to: [to], cc: cc ? [cc] : [], copy_me: copyMe, subject, message, lang: reportLang, + site_id: siteId ? Number(siteId) : null, include_empty: includeEmpty, + reports: [...picked].map((k) => { const [kind, fmt] = k.split('.'); return { kind, fmt } }), + }) + setDone(log) + } catch (e) { setError(e) } finally { setBusy(false) } + } + const name = (kind) => (kind === 'budget' ? t('report_budget') : kind === 'workbook' ? t('budget_file') : t('report_cost')) + + return ( + {t('close')} + : <> + + + }> + {done ? : <> +

{year.department} · {year.year} · {t('email_options', { lang: reportLang === 'en' ? 'English' : 'Português', site: siteName || t('all_sites') })}

+ setTo(e.target.value)} placeholder="nome@empresa.pt" autoFocus /> + setCc(e.target.value)} /> + + setSubject(e.target.value)} maxLength={300} /> +