# Changelog All notable changes to Client St0r will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [3.17.593] - 2026-10-10 ### Security: hardening pass across auth, vault channels, uploads and outbound requests **Authentication** - A user with a 2FA device must now pass it in the current session. Before, a password-only login (the stock `/admin/login/` form) produced a full session for staff accounts. Such sessions are now ended and sent through the 2FA login. - Removed `/api/auth/token/`. It issued the token the mobile API accepts from a password alone, with no 2FA step. Mobile sign-in (`/api/mobile/v1/auth/login/`, which enforces MFA) and API keys are unchanged. **Vault** - The REST API (`/api/passwords/…` reveal, `?reveal=true`, OTP) and the browser extension (reveal, TOTP) now go through the same gate as the web UI: per-credential `vault_view_password`, the reveal-approval requirement (single-use), and vault access rules, which fail closed. - The REST API no longer lists other users' personal (My Vault) entries, and create/edit/delete check `vault_create` / `vault_edit` / `vault_delete`. **GraphQL (`/api/v2/graphql/`, optional dependency)** - Every query and mutation is now limited to the caller's organizations, and mutations check the role capability on the target organization. Password entries expose metadata only. - CSRF protection is on (the endpoint is session-authenticated), and GraphiQL and the SQL debug middleware are only enabled with `DEBUG=True`. - Fixed the settings bug that made every GraphQL request fail with "A Schema is required", and fixed `createAsset` / `createDocument`, which never worked. `API_V2_GRAPHQL.md` now describes the real authentication. **Uploads** - Attachments are served with a content type derived from the file name, not the uploader's header. Only images, PDFs and plain text open in the browser; everything else (including SVG, HTML and XML) downloads. **Outbound requests** - Outgoing webhooks and outsourcing-partner callbacks go through the SSRF guard (`core/safe_http.py`). Internal addresses are refused unless `ALLOW_PRIVATE_IP_INTEGRATIONS=True`. - Creating, editing, testing or toggling webhooks now requires the org Admin role. **Other** - The emergency restart webhook is **disabled unless `EMERGENCY_RESTART_SECRET` is set**. It no longer falls back to a secret derived from `SECRET_KEY`. It is now POST-only and takes the secret in an `X-Emergency-Secret` header, never the query string. It is rate-limited, compares the secret in constant time, and no longer returns command output. `scripts/heal_all_servers.sh` is updated to match. - Secure-note link passwords are stored hashed. Existing plaintext ones are upgraded on first successful use. Wrong guesses are limited to 10 per note per client per 15 minutes. - Package names passed to the system-package updater are validated, so a value can't be read as an `apt-get` / `dnf` option. - WAN ping targets can't start with `-`. - The remaining hand-rolled client-IP helpers (audit log, API-key last-used IP, mobile, portal, PSA, network-discovery rate limit, and others) now use `core.client_ip` and honour `X-Forwarded-For` only from `TRUSTED_PROXY_CIDRS`. **Operator notes** - If you use `scripts/heal_all_servers.sh`, set `EMERGENCY_RESTART_SECRET` in each server's `.env`. - Anyone using `/api/auth/token/` should switch to an API key. ## [3.17.592] - 2026-10-05 ### Security: vault permissions and trusted proxies (PR #148, with a follow-up) Merges PR #148, plus a follow-up so it works behind this project's own deployment. **Vault (from #148)** - Reveal, OTP, QR-code, approval-request, break-glass and breach-test endpoints now check `vault_view_password` on the credential's own organization. Before, holding it in *any* organization was enough. - Personal credentials are visible only to their owner, superusers included. - Mobile create and edit check the target organization. Staff accounts get vault capabilities from their global role template, not from being staff. - If the access-rule check itself fails, the request is refused (503) instead of releasing the credential, and a decision with no `allowed` value counts as a denial. OTP and QR codes now go through the same access rules as reveal. **Client address (from #148)** - The firewall and vault access rules used the first `X-Forwarded-For` entry, which any client can set, so a public client could claim to be `127.0.0.1` and skip both. `core/client_ip.py` now accepts forwarding headers only from peers in the new `TRUSTED_PROXY_CIDRS` setting (default: loopback). It walks the chain back from the nearest hop, and a malformed chain fails closed. **Follow-up, needed for this deployment** - **Unix socket.** Native installs run nginx → gunicorn over a unix socket, where `REMOTE_ADDR` is empty. As merged, #148 resolved every such request to no address. With the firewall enabled, the whole site would have returned 403, and IP-based vault rules would have stopped matching. A unix-socket peer can only be a local process, so it is now treated as a trusted local proxy. - **LAN exemption.** #148 removed the private-address exemption from any request carrying forwarding headers. nginx always sets `X-Real-IP`, so that removed it for every proxied LAN user. The exemption now applies when the private address is the real client: either the request has no forwarding headers, or it came through a trusted proxy. What #148 was guarding against stays blocked: an unlisted proxy container's own private address doesn't exempt everyone behind it. - `.env.example` explains `TRUSTED_PROXY_CIDRS` for docker compose's `proxy` profile. **Rollout** - **Native installs with nginx on the same host:** nothing to do. - **Reverse proxy on another machine forwarding to gunicorn's port 8000:** add that proxy's address, e.g. `TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128,192.168.22.250/32`. Until you do, every request appears to come from the proxy. - **Docker with the `proxy` profile:** add the nginx container's address to `TRUSTED_PROXY_CIDRS`, or every request appears to come from it. - **Firewall allowlists:** check that they include your real public address. A forged `X-Forwarded-For: 127.0.0.1` no longer gets through. - No migrations. **Tests** - #148's 16 standalone tests in `security_tests/` (`python -m unittest discover -s security_tests`). - 13 Django tests in `core/tests/test_client_ip.py` covering unix-socket, loopback, direct, untrusted-proxy and configured-proxy peers, and the firewall's LAN exemption in allowlist mode. Run against #148 as originally written, the unix-socket and LAN-through-nginx cases fail. ## [3.17.591] - 2026-10-05 ### Removed: unused `static/img/vehicle-diagram.svg` A generic van side view left over from before the damage diagrams moved into the `templates/vehicles/*_diagram*.html` templates (redrawn in v3.17.589). No template, stylesheet, script or Python module references it. ## [3.17.590] - 2026-10-05 ### Removed: unused URL-fetching API key validators `APIKeyValidator.validate_connectwise_psa`, `validate_syncro_rmm` and `validate_generic_api_key` in `core/services/api_key_validator.py` took a caller-supplied URL and fetched it with a bare `requests.get()`, outside the SSRF guard added in v3.17.587/588. Nothing in the codebase called them, so they weren't reachable. They are deleted rather than guarded, so that nobody wires one back up and reopens the hole. The four validators that are in use (Anthropic, Google Maps, Twilio and Vonage) only call fixed vendor endpoints and are unchanged. ## [3.17.589] - 2026-10-05 ### Vehicle damage diagrams redrawn The van and pickup diagrams that technicians click to report damage were generic shapes, with oversized wheels and bodies that didn't match from one view to the next. All ten views (side, passenger side, front, rear and top for each vehicle) are redrawn at one real-world scale, so the five views of a vehicle describe the same vehicle. - **Van:** a high-roof commercial cargo van with a sloped hood, raked windshield, sliding side doors, rear barn doors with windows, pillar taillights and a high-mount stop light. - **Pickup:** a 3/4-ton regular cab with an 8 ft bed at stock height, with 8-lug wheels, a step bumper and a hitch receiver. - Line weight and palette are the same in every view, and the diagrams read on the dark panel and on white. There are no badges or logos. **Click areas:** - Every existing `data-area` name is kept, so past damage reports still highlight the right part. - The side views also gained the wheel and bumper areas that other views of the same vehicle already had. - `damage_location` is free text and nothing validates it against a list, so existing records are unaffected. **Left and right fixed.** The old front and top views were mirrored. In the front views, the vehicle's left headlight, mirror and wheel were drawn on the viewer's left; facing a vehicle, its left side is on your right. In the top views, the driver side was drawn at the top with the front pointing left. Both are corrected, so on those views some areas are now on the other side from where regular users will expect them. Templates only: no model, view, JavaScript or migration changes. ## [3.17.588] - 2026-10-05 ### Security: integrations and website monitors use the SSRF guard v3.17.587 added `core/safe_http.py` for the property URL import. The older checks in integrations and website monitoring had the same weakness that release fixed. They resolved the hostname with `gethostbyname`, judged that one address, and then let `requests` resolve it again to connect, so a name that changed between the two lookups (DNS rebinding) got through. Redirects weren't checked, `gethostbyname` ignores IPv6, and an unresolvable name was let through. Several providers had no check at all. **What changed.** Every integration session and the website monitor now connect through the guard, so the address checked is the address connected to. That holds on every redirect hop and every retry. **Policies.** `safe_http` gained `OutboundPolicy`, with three presets: | Used by | Public | LAN (RFC1918, CGNAT, ULA) | Loopback, link-local | Ports | |---|---|---|---|---| | Property URL import | yes | no | no | 80, 443 | | PSA, RMM, accounting, distributor APIs; UniFi Site Manager; website monitors | yes | only with `ALLOW_PRIVATE_IP_INTEGRATIONS` | only with `ALLOW_PRIVATE_IP_INTEGRATIONS` | any | | UniFi, Omada, Grandstream controllers | yes | yes | only with `ALLOW_PRIVATE_IP_INTEGRATIONS` | any | Network controllers normally live on the LAN, and the setup forms say so (`e.g. https://192.168.1.1`), so they reach LAN ranges without the opt-in. Under every policy, cloud metadata and credential endpoints (AWS, ECS, GCP, Azure, Oracle, Alibaba, including the IPv6 forms) and non-unicast addresses are refused, even with `ALLOW_PRIVATE_IP_INTEGRATIONS=True`. **Behaviour changes to know about** - UniFi, Omada and Grandstream previously had no check at all. A controller on `localhost` or a link-local address now needs `ALLOW_PRIVATE_IP_INTEGRATIONS=True`. LAN addresses keep working as before. - Before, only `is_private`, `is_loopback` and `is_link_local` were blocked. Now everything non-public is blocked by default, including CGNAT (`100.64.0.0/10`, used by Tailscale), reserved and documentation ranges. A PSA or monitor reached over Tailscale needs the opt-in. - Guarded sessions refuse to use an HTTP proxy, because the proxy would do the DNS lookup. With `HTTP(S)_PROXY` set, these connections fail rather than go around the guard. - A blocked connection shows as "Connection refused by address policy" on providers that use `BaseProvider`, and as "Security: …" on a monitor, rather than a generic connection error. **Plumbing.** `GuardedHTTPAdapter` takes a policy and `guard_session()` mounts it on an existing session, keeping its headers, cookies, `verify` and retry settings. A refusal raises `BlockedDestinationError`, which is also a `requests` `ConnectionError`, so existing handlers report it without code changes; it is raised outside urllib3, so it is never retried. The monitor's separate certificate-info socket now connects to the validated address too. Xero and QuickBooks API calls go through the provider's guarded session instead of bare `requests.request`. Alga no longer replaces the guarded `BaseProvider` session with a bare one. **Tests:** 11 policy and guarded-session tests in `core/tests/test_safe_http.py`, 8 wiring tests in `integrations/tests.py` that fail if any provider goes back to a bare session, and 5 monitor tests in `monitoring/tests.py`, including one that the certificate fetch doesn't re-resolve the name. **Not changed:** calls to fixed vendor hosts (Microsoft Graph, the Xero and QuickBooks OAuth endpoints) still use plain `requests`, since nothing a user configures reaches them. `core/services/api_key_validator.py` has three URL-taking validators with no callers; they are dead code, left for a separate cleanup. No migrations or new settings. ## [3.17.587] - 2026-10-05 ### Security: property URL import could reach internal addresses (SSRF) `POST /locations//import-property-from-url/` takes a URL from the browser and fetches it server-side. The only check was that the string started with `http://` or `https://`; the fetch was a plain `requests.get()` that followed redirects and honoured proxy environment variables. Any logged-in member, including a read-only one, could have the server request `127.0.0.1`, RFC1918 hosts, `169.254.169.254` or anything else it could route to, and failures echoed the exception text back. Present since the feature arrived in v2.11.4. **New: `core/safe_http.py`** — one place for fetching user-supplied URLs. - The URL must be http(s) on port 80 or 443, with no embedded credentials, no `localhost`/`.local`/`.internal`-style names, and no internal IP written any way: dotted, decimal, hex, octal, shortened, IPv6, IPv4-mapped, NAT64, or with a zone id. - The authoritative check runs when the socket opens, inside urllib3. The hostname is resolved once; if *any* address is loopback, private, link-local, CGNAT, multicast, reserved, documentation or metadata, the request is refused. Otherwise the socket connects to that validated IP literal, so no second lookup can rebind it, and the connected peer is checked again. The hostname stays on the connection, so TLS SNI, certificate verification and the Host header behave as before. - Redirects are followed manually, at most three, and each target goes through both checks again. Proxies and `~/.netrc` are ignored. - 5s connect and 10s read timeouts, a 25s total deadline, a 2 MB body cap (checked against Content-Length and while streaming), and HTML/text content types only. **The endpoint** now requires write access (`@require_write`), returns a generic message for each failure class with the detail in the log, and tolerates AI-extracted numbers like `"5,000"` instead of failing on `int()`. **Tests:** `core/tests/test_safe_http.py` runs real HTTP through requests and urllib3 against a local server with DNS faked, covering internal addresses, rebinding, redirects, size, timeouts and content types. Disabling the connection guard makes the rebinding, peer-check and resolution tests fail. `locations/tests.py` covers the endpoint's authorization, input handling and error messages. No migrations, settings or new dependencies. `ALLOW_PRIVATE_IP_INTEGRATIONS` deliberately does not apply: it is for operator-configured integrations, not URLs typed by users. ## [3.17.586] - 2026-10-03 ### "Help Us Grow" — a share-first call to action The navbar heart has always opened a Support modal: three links and a request for a GitHub star. The thing most likely to actually help — one person telling another person about the project — was the one thing it did not make easy. Rebuilt around sharing, with every destination moved into one config file so the same partial can be dropped into our other apps. **The entry point.** The heart is now a labelled pill, "Help Us Grow", in the utility cluster next to the star, Beta app and Install app. It pulses: two beats inside the first 900ms of a 4.5-second cycle, with a red glow. The scale change is small enough that the button's box never moves, the glow stays inside the pill, and hovering pauses the beat rather than competing with the pointer for attention. v3.17.559 had added a rule that killed this animation outright — "the support heart used to pulse forever in the corner of a work tool". That rule is gone, by request. `prefers-reduced-motion: reduce` now removes the movement and keeps the glow and the colour, so respecting the preference is not a downgrade to a plain grey icon. A test asserts both halves of that. The label shows at 1750px and up and in the collapsed drawer below 1400px. Between those widths the navbar is measurably full (see v3.17.585), so the heart carries the button alone — the `aria-label` and the tooltip both still say "Help Us Grow". Measured in Chromium at 1920, 1750, 1600, 1440, 1024 and 390: the full button fits with no clipping and no horizontal page overflow at any of them, and the search field keeps its width. **Sharing leads.** The first card in the modal, verified visible without scrolling at 1440×900 and at 390px: - **Share This Project** uses `navigator.share` where the browser has it, so the OS share sheet does the work. Where it does not, the button copies the message instead and the per-network buttons below are always rendered. - **Copy Link** and **Copy Ready-to-Post Message**, plus LinkedIn, Facebook, X and email. Every one opens a draft the user sends themselves — nothing is posted on anyone's behalf, and the modal says so. - The suggested message is shown in an editable field so it can be read and reworded before it goes anywhere. It is assembled in the config module from the project name, description and URL, and is held under X's 280 characters with the link counted at its shortened 23 — there is a test for that, because a truncated share message loses the URL. **The rest of what we build.** MSP Reboot and MSPZero each get a Visit action and a *separate* Share and Copy link action, with Share as the emphasised one. Plus a ready-to-copy message for the network as a whole. Only this project is described as open source; the others are products and services and their licensing is not ours to state. A test asserts no network entry describes itself as free or open source. **GitHub, sponsorship, the business.** A star button, the GitHub profile, the existing verified GitHub Sponsors link (unchanged — it is preserved, not replaced), and MSP Reboot with its Facebook page. The sponsorship card omits itself entirely when no destination is configured, rather than rendering an empty ask; so does the star button when no public repository is known. **One place for the destinations.** New `config/support_links.py` holds the project, the network, GitHub, the sponsor link and the business, overridable per install through a `SUPPORT_LINKS` setting that merges one level deep. The two template partials carry no URLs at all and a test fails if one appears in them. What gets shared is the public project URL — never the address of the running install, which is a private dashboard, often a tenant hostname, and may carry a token. `PROJECT.public_url` is currently the public repository, because there is no ClientSt0r product site (`docs/github-about.md` records that `clientst0r.mspreboot.com` has no DNS record). If one is stood up, that single value is the only edit needed. **Accessibility.** Bootstrap's modal already gives the focus trap, Escape and focus restoration; all three were verified in Chromium through a real click on the trigger rather than assumed. Copy and share results are announced through one `role="status"` live region, and "Link copied!" / "Message copied!" appear **only** after the clipboard write actually resolves. When it is refused — and in headless Chromium it is, which is how the path got tested — the code falls through to `execCommand`, then to selecting the visible message field and saying so. A cancelled share sheet is not reported as a failure. **One bug caught in review, worth naming.** Django's `{# #}` is single-line only. Six multi-line `{# ... #}` blocks in the new partial rendered as body text, and the words "Star This Project" from an explanatory note appeared inside the modal — which also made a test pass that should have failed. All six are `{% comment %}` blocks now and a test rejects any multi-line `{# #}` in either partial. **Also.** `custom.css:549` removes the border from every `.btn` and only gives it back to `btn-outline-primary`, so Copy Link and friends rendered as bare grey text. Given an edge inside this modal only; the rest of the app is untouched. Existing entry points are preserved: the modal keeps its `supportProjectModal` id, so the account menu item still opens it (relabelled to match). **Files.** New: `config/support_links.py`, `templates/core/_help_us_grow_modal.html`, `templates/core/_help_us_grow_button.html`, `static/js/help_us_grow.js`, `core/tests/test_help_us_grow.py` (45 tests). Changed: `templates/base.html`, `static/css/ui.css`, `core/context_processors.py`, `config/settings.py`, `core/tests/test_navbar_utility_buttons.py`. No migrations. No new dependencies. No new views or URLs. `manage.py test core accounts` — 384 tests, all passing. ## [3.17.585] - 2026-09-18 ### The utility controls are visible buttons again Beta app, Install app and Support this project sat behind an ellipsis dropdown between the star and the search box. v3.17.559 put them there to stop the bar colliding with the search field; v3.17.580 then freed the room by grouping Security, CRM and Reports under the main navigation's More menu. A control nobody can see is a control nobody uses, so they are individual buttons again — same order, same icons, same behaviour, with Support still opening the same modal. Each has an `aria-label`, a descriptive tooltip, a focus ring that is visible against the dark bar, and `aria-hidden` on its icon. The Support button opens a modal, so `data-bs-toggle` is spoken for; its tooltip is hooked with `data-bs-tooltip` and the initialiser now looks for both. This is **not** the main navigation's More menu (`moreNavDropdown`), which stays a dropdown along with Admin. A test asserts that. Verified in Chromium at seven widths rather than by reading markup — 2560, 1920, 1600, 1536, 1440, 1024 and 390. Navbar height is identical to the pre-change baseline at every one (72 / 70 / 88 / 88 / 88), so three extra buttons cost no vertical space. Below 1400 the navbar is already the drawer, where they render as full-width labelled rows; nothing is ever behind an overflow menu. Two findings from measuring rather than assuming: - **Wrapping was the wrong answer.** Tried first: at 1536 the right-hand group is squeezed to roughly 600px, so `flex-wrap` put one control per row and made the navbar 345px tall. A compact `.nav-util` footprint fits everything on one line instead. - **The first attempt broke the layout for an unrelated reason.** The explanatory comment was a two-line `{# ... #}`. Django honours those on one line only, so the second line rendered as an 858px text node inside the navbar and pushed the controls onto three rows. `test_no_multi_line_hash_comments` exists for exactly this and now runs as part of this change's loop. ## [3.17.584] - 2026-09-18 ### Bundled services are saved, and no longer silently deleted (issue #147) Reported by @roccordx, who diagnosed it exactly: the bundle editor bound its submit handler with `document.querySelector('form')`, which returns the first form in the *document*, not the nearest one. `base.html` renders the navbar — which contains the search form — before the content block, so `serialize` was attached to the search box and never ran when the contract was saved. `bundle_items_json` posted empty every time. Their fix, `hidden.closest('form')`, is applied. Two things the browser could not show: **It was destroying data, not just failing to save.** The view reconciles by deleting rows absent from the submitted JSON: item.bundle_items.exclude(pk__in=seen_pks).delete() With the field empty, `seen_pks` is empty — so every existing bundle item was deleted on *any* save. `psa_auto_renew_contracts` copies bundle items onto renewal contracts, so rows exist that this form never created; editing a renewed contract's name silently wiped its bundle. The view now separates a submission that carries no `bundle_items_json` at all (the editor never ran — do not reconcile) from one carrying an empty list (the user cleared every row — delete them). Malformed JSON no longer wipes the bundle either; it used to fall through to `[]`. **The same bug was in `core/settings_ai.html`**, where the restart overlay and the double-submit guard were bound to the search box for the same reason. Fixed there too. `test_no_template_binds_to_the_first_form_in_the_document` scans every template and fails on the pattern, so it cannot come back. Ten tests in all. ## [3.17.583] - 2026-09-18 ### A scheduled task that fails no longer records success Nine of `run_scheduler`'s `run_*` methods wrapped their `call_command` in `try/except Exception` and wrote the failure to stdout. `run_task` then returned normally, `handle` called `task.mark_completed()` with no error, and the task recorded `last_status='success'`. A nightly job could fail every night while the Scheduled Tasks page showed a green tick for it; the only trace was a line in the systemd journal. The guards were not adding protection. `handle` already calls `mark_completed(error=...)` on an exception, which records `failed` along with the message — so removing the guards is what lets the existing, correct handling do its job. Affected: network config backup, PSA sync, password breach scan, equipment catalog update, Python dependency scan, update check, cleanup stuck scans, scheduling alerts, and security scan. The PSA one shows how this survives. Three layers each assumed another would report the failure: `sync_psa`'s `sync_connection` swallows per-connection errors, so the command never raises, so `run_psa_sync`'s guard never fired, so the task marked success. Its comment read "PSA sync might not be configured, that's okay" — but `sync_psa` handles that case explicitly, with a warning and a clean return. The guard defended against something that could not happen while suppressing everything that could. A task that legitimately has nothing to do still returns quietly and reports success, the way `run_asset_age_check` does when its feature is switched off. That is different from failing and is unchanged. `test_no_run_method_catches_an_exception_without_re_raising` parses the module and fails if any `run_*` swallows again, so the pattern cannot creep back. All nine are confirmed failing against the previous code. ## [3.17.582] - 2026-09-18 ### A sync that drops records no longer reports success Both `PSASync` and `RMMSync` catch per-record failures, count them and carry on. That part is right: one malformed record should not abandon the whole run. But they then wrote `last_sync_status = 'success'` unconditionally, so a sync in which every record failed reported success, with an empty `last_error` and a green tick on the integrations dashboard: companies created : 0 companies errors : 5 connection status : 'success' connection last_error: '' The incremental cursor compounded it into data loss. `updated_since` is taken from `last_sync_at` only when the last status was `'success'`, so a run that silently "succeeded" while dropping records moved the cursor past them, and the next run asked the provider only for records changed since. The dropped records were never offered again unless something changed them upstream. Recording the truth fixes both halves at once. A run with per-record errors now ends `'partial'`, with `last_error` naming what was dropped — and because the status is no longer `'success'`, the next run falls back to a full sync and re-offers exactly the records that failed. `apply_sync_outcome()` is shared by both sync classes so they cannot drift apart on this again. The audit log entry and the log line follow the same distinction: a partial run is recorded with `success=False` and logged at warning rather than info. `connection_status()` gains a `partial` state, checked **before** the broken branch. A partial sync writes its summary into `last_error`, and the broken branch decides by that field being non-empty, so in the other order every partial sync would have reported as broken. It renders as an amber "ON · Partial" pill whose tooltip says the records will be retried; the PSA and RMM detail pages gained a matching badge, since `partial` would otherwise have fallen through to a grey "N/A". Eleven tests, including one that asserts the *next* sync passes `updated_since=None` — that is the data-loss half of the bug, and the part worth catching a regression in. ## [3.17.581] - 2026-09-18 ### Expired contracts were invoicing the client forever A contract that reached its `end_date` and was not set to auto-renew went on generating an invoice every month, indefinitely. A contract that ended three months ago still raised a $500 invoice today. Nothing in the system closed the loop: - `psa_generate_recurring_invoices` filtered on `status='active'` and `next_billing_date <= today`. It never looked at `end_date`. - `psa_auto_renew_contracts` only handles `auto_renew=True`. - `psa_advance_subscription_lifecycle` only cancelled contracts explicitly flagged `cancel_at_period_end`. - `expired` has been a valid `Contract.status` since the model was written and nothing ever assigned it. `Contract.for_ticket()`, meanwhile, has always respected `end_date` — so the client stopped being covered for tickets while continuing to be billed for the contract. Those two halves disagreeing is what kept it out of sight: the symptom was an invoice arriving, not an error appearing. Fixed at both levels, because they answer different questions: - **The invoice cron** now also requires `end_date` to be null or today or later. This is the safety net and holds even on an install where the lifecycle cron has never run — which `test_the_filter_holds_even_when_the_lifecycle_cron_never_ran` pins. - **The lifecycle cron** expires ended contracts, so the contract list, `for_ticket` and billing finally agree on what is live. The expire job is deliberately narrow. It skips `auto_renew=True` contracts, which belong to `psa_auto_renew_contracts` and would otherwise be raced; the exception is one that already has a renewal child, which has been succeeded and is no longer the live agreement to bill. A contract ending *today* still bills its final period. Twelve tests, six of which fail against the previous code. ## [3.17.580] - 2026-09-18 ### The top menu bar stops overflowing Ten top-level menus — Dashboard, Assets, Vault, Docs, PSA, Security, Operations, CRM, Reports, Admin — plus the search box, organization pill and user menu did not fit the bar at any realistic width. v3.17.559 moved the loose utility icons into the ⋯ menu for the same reason; this does the same job one level up. Security, CRM and Reports are now headed sections of a single **More** menu, grouped the way the Admin menu already groups System / Security / Management / Mobile. Nothing was removed: all eighteen destinations are the same URLs, and a test asserts each one still appears in the rendered navbar rather than checking the template source. Operations stays top-level deliberately. Nineteen children is too many to put behind a second click — that would trade a width problem for a depth problem. The feature flags travelled with the sections. Security was nested inside `{% if psa_enabled %}` alongside PSA, and CRM had its own `{% if crm_enabled %}`; both guards live inside the new menu, as does the `{% if user.is_superuser %}` around Security Dashboard and Vulnerability Scans. There is a test for each, so turning PSA or CRM off still removes exactly what it used to. ### `Contract.generate_invoice()` stamps its own `last_billed_at` The method reset its meters' `last_billed_at` but left the contract's own stamp to the caller. `psa_generate_recurring_invoices` is the only caller and did stamp it, inside the same transaction — so the field was right in practice, and right only because there was one caller. A second one, a "bill this contract now" button say, would have skipped it silently, and `_proration_factor` reads that field to decide whether to prorate a first invoice. The method now stamps what it is responsible for, the way it already did for meters. It is written after `_proration_factor` has read the pre-billing value, so it cannot change the amount just invoiced — there is a test for that specifically, since a stamp written too early would have silently switched off proration for the very invoice being raised. ## [3.17.579] - 2026-09-18 ### `psa_audit_late_fees` — find late fees charged on already-credited money v3.17.578 stopped the late-fee cron charging a percentage of money a credit memo had credited back. It did not undo the `Charge` rows already written, and those are real charges on a client's account rather than a display artefact. This read-only command reports them. It reconstructs each fee's working from the charge description, which `psa_apply_late_fees` writes as `(overdue $1000.00, 5.00% applied)` — so the amount billed on and the rate used are both recoverable rather than inferred from current state. Against that it sums only the credits that existed **when the fee was raised**: a memo issued afterwards does not make the charge wrong, because it was correct on the day. What remains is what the fee should have been. Following `psa_tax_audit`, it writes nothing — no charge modified or removed, no invoice recomputed — and leaves the correction to you and your accountant. Fees already carried onto an invoice are listed separately, since those want a credit memo rather than simply deleting the charge. Two things it flags rather than skipping: descriptions that do not match the expected format (someone retyped one by hand), and fees naming an invoice that no longer exists. Both are listed for manual review instead of being silently dropped from the count. manage.py psa_audit_late_fees manage.py psa_audit_late_fees --org acme --since 2026-01-01 manage.py psa_audit_late_fees --csv /path/to/late-fee-audit.csv ## [3.17.578] - 2026-09-17 ### Credited invoices were still being charged late fees Issuing a credit memo does not touch the credited invoice's `amount_paid` or `status` — the memo is a separate negative invoice pointing back through `credits_invoice`. So `Invoice.balance` still read as the full amount due, and `psa_apply_late_fees` used that figure directly. An invoice credited **in full** was therefore charged a late fee on the whole original amount: invoice total=1000.00 balance=1000.00 status=sent memo total=-1000.00 credits_invoice=1 LATE FEE CHARGED: 50.00 — Late fee for INV-2026-00001 (overdue $1000.00, 5.00%) Unlike v3.17.577, which showed wrong figures, this one wrote a real `Charge` against the client. Any install running late fees alongside credit memos has been billing customers for money they do not owe. `Invoice` gains `credited_amount` (what non-void credit memos have credited back, as a positive magnitude) and `net_balance_due` (`balance` less that, floored at zero). The late-fee command uses the net figure, so a partial credit reduces the fee proportionally — 5% of the $600 still owed, not of the original $1,000 — and a fully credited invoice is charged nothing. `net_balance_due` is floored deliberately: an over-credit is credit on the account, not a debt owed in reverse, and `get_psa_balance` already accounts for it there. The two fixes compose without double-counting. Credit memos are now also excluded from the late-fee query explicitly. They were previously excluded only by the accident that `amount_paid < total` is false when the total is negative. Both new behavioural tests fail against the previous command. ## [3.17.577] - 2026-09-17 ### Credit memos now reduce what the client owes There are two kinds of credit in this system and only one of them counted. An **account credit** is a `Charge` with `is_credit=True`, and `get_psa_balance` subtracted it. A **credit memo** is an `Invoice` with `is_credit_memo=True` whose line prices are negated, so its balance comes out negative — and the balance loop began `if bal <= 0: continue`, which skipped it entirely. Issue a $500 credit memo against a $2,000 invoice and nothing moved: outstanding stayed $2,000, credits stayed $0, net balance stayed $2,000. The client account page and the aging report both went on showing the full amount due, so the client was chased for money that had already been credited to them. `is_credit_memo` was set on creation, guarded against double-crediting, and excluded from both the contract duplicate check and the tax audit — the balance calculation was the one place that never consulted it. `get_psa_balance` gains `invoice_credits`, subtracted from `net_balance`. `outstanding` keeps its existing meaning as gross receivables, because the aging columns are built from it, and credit memos stay out of the aging buckets: a credit is not a receivable and has nothing to age. An invoice paid beyond its total lands in the same bucket by the same arithmetic — the overpayment is money the client is owed. Two knock-on fixes: - The aging report skipped any client with nothing outstanding and no account credit. A client whose only balance was a credit memo dropped off the report entirely, hiding exactly the row that needed actioning. - Both templates showed one kind of credit and silently omitted the other. They now name account credits and credit memos separately. Six of the eight tests in `core/tests/test_billing_credit_memos.py` fail against the previous code. ## [3.17.576] - 2026-09-17 ### Floor-plan generation follows the configured LLM provider The last AI surface building its own provider client. `AIFloorPlanGenerator.__init__` constructed `anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)` and `_get_ai_design` called it directly, so an install running Ollama for data residency still sent its building briefs — dimensions, headcount, department structure, security requirements — to a third party. It now goes through `LLMProvider.generate` like every other AI feature. The view in front of it checked `ANTHROPIC_API_KEY` specifically, which told an install running Ollama to add an Anthropic key for a feature that no longer needs one. It now asks `is_llm_configured()` and names whichever provider is actually selected. The existing fallback layout is kept and now covers three cases rather than one: no provider configured, the provider failing, and output with no JSON in it. The constructor takes an injectable provider with a sentinel default, so "supplied nothing" stays distinct from an explicit "no provider". `test_no_ai_surface_still_hardcodes_a_provider_client` closes the sweep that v3.17.575 began: it parses every module in the project and fails if any of them constructs a provider client directly. Two exceptions are documented in the test — the provider layer itself, and `core/services/api_key_validator.py`, which validates an Anthropic key typed into Settings and so has to talk to Anthropic rather than to whatever is configured. ## [3.17.575] - 2026-09-17 ### Receipt scanning follows the LLM provider you configured The same feature had two implementations that disagreed about where your data goes. Scanning a receipt in the mobile app went through `get_configured_provider().extract_receipt_fields(...)` and honoured the configured provider. Scanning the same receipt in the web UI built its own `anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY)` client and went to Anthropic regardless. On an install running Ollama — chosen precisely to keep data on the premises — every receipt image scanned from the web still left the network, along with whatever is on it: vendor, amounts, dates, often card digits and locations. `vehicles/services/receipt_ocr.py` now routes through the provider layer like the mobile scanner does. Its duplicated prompt, code-fence stripping and type coercion go with the duplicated client; `LLMProvider.extract_receipt_fields` already implements all of it for Anthropic, OpenAI and Ollama. The two schemas differ — the provider layer returns `amount_total`, `amount_tax`, `category_hint` and `line_items` under an `extracted` key, while the vehicle receipt form speaks `amount`, `tax_amount`, `category` and `description` — so the mapping is explicit, mirroring what `api_mobile.views_receipts` already does. Getting it wrong would have produced a form with blank amounts and OCR that looked like it had worked, so the mapping has a test of its own. Two incidental gains: `gallons` and `cost_per_gallon` reach the form for the first time, since the hand-rolled prompt never asked for them, and `confidence` is reported as unknown rather than invented. ## [3.17.574] - 2026-09-17 ### The vault export permission stops advertising a feature that does not exist `vault_export` is defined on every role template, is a labelled checkbox ("Export passwords") in the role editor, and is displayed in the role list. Nothing anywhere checks it, because Client St0r has no bulk password export — there is no view and no URL. Granting or denying it changed nothing at all. It is now labelled as unimplemented in both places it appears, with a note saying plainly that the setting is stored but grants nothing. The field is kept rather than dropped so a role's stored intent survives until the feature exists, and its `help_text` is deliberately left alone: changing it would generate a migration for a cosmetic string. Building the export was considered and deliberately not done. A bulk password export is a path that decrypts every credential in an organization and hands it over as a file; it needs master-key handling, per-record audit, and probably re-authentication and an approval step. That is a feature to design, not something to add while fixing adjacent bugs. `core/tests/test_vault_export_permission.py` pins the honest state, and two of its tests fail the moment anyone builds the export or adds a gate — so the label cannot quietly become a lie in either direction. ## [3.17.573] - 2026-09-17 ### The data export had never exported anything, and the KB export skipped subsidiaries **Every type of data export failed on its first row.** Settings → Data Export builds its payload with three serializers, and each read a field its model does not have: - `_serialize_asset` read `asset.location` and `asset.status`. `Asset` has neither. Status lives in `custom_fields`, the way `assets.views.asset_list` filters it; there is no location field at all, so that key is dropped rather than invented. - `_serialize_document` read `doc.content`. `Document` stores its text in `body`. - `_serialize_password` read `pwd.password_encrypted`. The field is `encrypted_password`. Each raised `AttributeError`, which the view's blanket `except` turned into `{"success": false, "message": "Export failed: ..."}` — returned with HTTP 200, so the browser saved it as the download. Assets, Documents, Passwords and All have produced nothing but that error since the feature was written. Only Contacts worked. **The export ignored the organization selector.** It read `Model.objects.all()` regardless, so a superuser exporting while looking at one client silently got every client in the install. It now scopes to the selected organization and its descendants, as do the counts heading that page. **Passwords in the Hudu and IT Glue formats.** `_format_for_hudu` and `_format_for_itglue` map assets, documents and contacts, and never touch passwords — so that combination was a successful download containing no passwords at all. It is now refused with a reason. The JSON export's `password_encrypted` values are this install's ciphertext, readable by nothing else; the file now says so rather than leaving the recipient to find out. **Knowledge Base scoping.** `document_detail` began including descendant organizations in v3.17.571 while `document_list` still filtered a bare `organization=org`, so a parent organization could open a subsidiary's document by slug but never saw it listed. Its category and tag dropdowns had the same limit, which would leave a subsidiary's category unselectable beside a document visible in the same list. `document_export_bulk` shared the fault, and handing a departing client one archive of all their documentation is that endpoint's stated purpose — for a parent company it silently omitted every subsidiary document the list had just shown them. All nine tests in `core/tests/test_docs_export_scoping.py` fail against the previous code. ## [3.17.572] - 2026-09-17 ### Hotfix: the abuse middleware was rejecting the mobile app v3.17.569 fixed `AIAbuseControlMiddleware` so that it matched real endpoints for the first time. It had never matched anything, which is why nobody had noticed that its first action on a matched request was: if not request.user.is_authenticated: return JsonResponse({'error': 'Authentication required'}, status=401) `api_mobile` authenticates with an `Authorization: Token ...` header that DRF resolves *inside* the view, so `request.user` is still anonymous out at middleware level. From v3.17.569 every token-authenticated call to an AI endpoint — the mobile app's receipt scanner among them — got a 401 before its view ran. Fixing the matching activated a latent bug. The lesson is that switching on dead code means auditing what it does, not only that it now runs. This middleware caps usage; it does not authenticate. An anonymous-at-this- point request is now passed through for the view's own authentication to accept or refuse. Usage is still recorded afterwards, once DRF has populated `request.user`, so a token client's caps engage from its next request. `_track_usage` also no longer assumes an authenticated user, which would have raised on `AnonymousUser.id`. `MiddlewareDoesNotAuthenticateTests` pins both halves. Found by `api_mobile.MobileOcrEndpointTests.test_ocr_disabled_returns_503` failing (401 != 503) in the full suite run for v3.17.571 — the one failure in 2611 tests, and inherited rather than caused by that release. ## [3.17.571] - 2026-09-17 ### A list that shows a row now links to a detail page that opens it `Organization.parent`'s own help text reads "The parent's queries see descendants' rows; descendants stay scoped to themselves", and the list pages implement exactly that through `OrganizationManager.for_organization()`. The detail, edit and delete views those lists link to looked their row up with a bare `organization=org`, which stops at the selected organization. So a parent organization's asset list rendered a subsidiary's server and the link to it returned 404. The same split ran through passwords, contacts, inventory, scheduled tasks, documents and every integration connection — 78 lookups across nine modules, each one a dead link from a row the user was already being shown. `core/tenancy.py` gains `get_org_object_or_404`, and those 78 lookups now use it. It is deliberately *not* the existing `get_scoped_object_or_404`, which spans every organization the user is a member of: PSA and resourcing want that wider rule and say so, but applying it here would let someone who belongs to two clients open the unselected one's record by URL. The new helper is the current-organization rule — the organization in the switcher, plus its descendants — which is what the lists already do and nothing more. Only models whose lists already include descendants were changed. A model whose list is strict (`Diagram`, `Process`, `APIKey`, `AuditLog`, `Membership`, `Rack`, `OrganizationCompliance`) keeps strict detail scoping: there is no broken link to repair there, and widening it would be a policy change rather than a fix. Three things found while making the change: - **`M365Connection` was missed on the first pass.** Its name contains digits, and the scan that decided which models qualified captured names with `[A-Za-z_]+`, which stops at the `3` — so it appeared in the results as a model called `M` and its five lookups stayed strict while the integrations dashboard listed it with `for_organization`. Found by redoing the scan with a pattern that allows digits, before this shipped. - **`monitoring/api_views.py` wrote one of these lookups as a conditional with the unscoped branch first** (` if not org else `). Mechanically rewriting it inverted the branches, which would have handed any organization's asset to any caller through a JSON API. Caught before commit; `RackDeviceAssetScopeTests` now pins the branch rather than its spelling, so the inversion cannot come back silently. - **Eight blanket `except Exception` handlers in that module swallowed `Http404`**, turning every tenant refusal into a 500 with a logged traceback. They now re-raise it, so a refusal is a 404 as intended. ## [3.17.570] - 2026-09-17 ### Global search now sees what the list pages see `core.search_views.global_search` filtered a bare `organization=org` on every one of its six content types. The list pages it mirrors go through `OrganizationManager.for_organization()`. Two consequences: - **Phase 18's organization hierarchy was ignored.** `for_organization()` includes descendant orgs, which is the whole point of the parent/child structure — a holding company's queries see its subsidiaries' rows. Search did not. A user at a parent org saw a subsidiary's assets, contacts, documents and passwords on every list page, and got nothing for them here. - **Global view returned nothing at all.** For a staff user or superuser with no organization selected, `org` is None, so `filter(organization=None)` matched no row. Search came back empty across all six content types while every list page showed the whole install. Both are under-returns, which is why neither was ever reported: a search saying "no results" is indistinguishable from the thing not existing. Scoping now follows `assets.views.asset_list` exactly — global view searches everything, everyone else searches their org and its descendants, and a user with neither gets an explicit empty queryset rather than an accidental one. PSA companies and contacts are scoped by their own `organization` the way `integrations.views` does them, instead of reaching through the connection. Because a result set can now span organizations, each row carries the owning org's name; the badge is suppressed when the results cannot span orgs, where it would just be noise. The PSA branch still tolerates the tables being absent on an install that has never configured an integration, but it logs instead of swallowing the exception silently. Seven of the eleven new tests in `core/tests/test_global_search_scoping.py` fail against the previous implementation. ## [3.17.569] - 2026-09-16 ### The AI spend controls never ran, and the master switch missed six endpoints **`AIAbuseControlMiddleware` guarded nothing.** It decided what to cover by matching two hand-typed path prefixes: '/locations/generate-floorplan/', '/api/ai/', The floor-plan route is `/locations//generate-floor-plan/` — different spelling, and missing the id segment — and `/api/ai/` has never been a route in this project. `_is_ai_endpoint` therefore never returned True. The middleware was in `MIDDLEWARE` and fell straight through on every request for its entire life: no per-user request cap, no per-org request cap, no spend cap, nothing recorded, on any AI endpoint. Two more faults sat behind that one, each enough on its own to disable half the control had the matching ever worked: - It read `request.organization`. `CurrentOrganizationMiddleware` sets `request.current_organization`; nothing sets `organization`. Both org-level caps were skipped. - `_check_limits` reads `ai_spend_user_*` / `ai_spend_org_*`, and `_track_usage` only ever wrote the request counters. Nothing wrote the spend keys, so the dollar caps could not fire. And `get_ai_usage_stats()`, which reports the numbers, has no callers — so nobody ever saw that they were always zero. Matching is now by resolved URL name against `AI_ENDPOINT_NAMES`, which lists all twelve endpoints that reach a provider, so a route can be re-spelled without disarming the control, and a test fails if one is renamed or removed. Full resolution costs ~120us on a miss, which would be pure waste on the static files and ordinary pages that are nearly every request, so a prefix check runs first and only candidates are resolved — 0.6us for everything else. A second test keeps the prefix list and the endpoint list in step, so the fast path cannot quietly stop covering something. Org attribution goes through `get_request_organization` like the rest of the app. `record_ai_spend()` is what feeds the spend caps: the middleware sees a response, not a token count, so it does not invent a cost — a caller that reports nothing is bounded by the request caps, and the PSA AI path keeps its own finer-grained token ceiling in `psa_ai.services.guardrails`, which was working all along. **Six endpoints never consulted `psa_ai_enabled`.** Project convention is that every AI feature is gated by the master switch. The documentation assistant (`ai_assistant`, `ai_generate`, `ai_enhance`, `ai_validate`), asset AI documentation and floor-plan generation each checked only that a provider was configured — so an admin who turned AI off in Settings still had all six generating, and still spending against the configured provider. They now go through the new `core/ai_gate.py`, which is also where the three independently-grown copies of the check (`psa_ai.views._ai_on`, `security_alerts.ai_summarizer.is_ai_enabled`, `docs.views._docs_ai_ready`) now read the flag from, so they cannot drift apart again. ## [3.17.568] - 2026-09-16 ### Dashboard widgets now answer to permissions and to tenancy Every dashboard and wallboard widget aggregated across every client in the install and checked no permission beyond `reports_view_dashboards` — which every role template, down to Read-Only, grants by default. The report pages are careful about this. `psa_profitability_by_client` is behind `reports_view_financial`; `psa_sla_trends` is behind `reports_view_sla`; `agreement_reconciliation` narrows to the caller's own orgs unless they are staff. The widget layer showed the same numbers with neither check. A read-only member of one client could open a shared — or global — dashboard and read the MSP's total revenue, its top clients by name and invoiced amount, MSP-wide unbilled hours, SLA breach trends, failed-login counts and vault activity. Nothing in the UI suggested the tiles were anything other than their own. `reports/widget_sources.py` now states, per data source, both answers: - **`perm`** — the permission the equivalent report page requires, or None when any dashboard viewer may see it. Money sources take `reports_view_financial`, the SLA trend takes `reports_view_sla`, the CRM/inventory/audit-backed ones take their own module's view permission. - **`scope`** — `SCOPE_ORG` for a source that honours the viewer's client orgs, `SCOPE_MSP` for one whose rows have no client to scope by (django-axes attempts, the user table). An `SCOPE_MSP` source is shown to someone entitled to every client and refused to everyone else, rather than shown unscoped. `viewer_for(user, is_staff_user)` builds the viewer context by the same rule `psa.views._scoped_ticket_qs` applies to tickets: superuser and MSP staff see every client, everyone else sees their active memberships' orgs. All four render paths — dashboard detail, wallboard view, wallboard rotate and the per-widget category refresh — pass it. Two details worth naming: - **`get_widget_data` without a viewer now means no access, not full access.** A call site that forgets one renders visibly empty tiles instead of quietly handing every client's data to whoever asked. A data source missing from `WIDGET_SPECS` falls back the same way. - **A restricted widget says so.** Both templates already render an `error` key as a warning tile, so the viewer gets the reason in place of the numbers rather than a plausible-looking zero. `reports/queries.py` grew `scope_to_organizations()`, which lets the query functions the widgets call take a list of client orgs as well as the single one they already accepted. No caller's existing behaviour changes. ## [3.17.567] - 2026-09-16 ### Workflow completion notes now actually reach the PSA ticket `PSAManager.add_ticket_note` decrypted the connection's credentials with `decrypt_v2(connection.encrypted_credentials)`. `PSAConnection` does not store that column as one encrypted blob — `set_credentials` writes `json.dumps(encrypt_dict(...))`, a JSON object whose *values* are individually encrypted. `decrypt_v2` was therefore handed a string starting `{"api_key": ...`, failed to base64-decode it, and raised; `_get_credentials` caught that and returned `None`; and all five provider branches returned `False` on their first line. No workflow completion note has ever reached a PSA ticket, for any provider, since the manager was written. Nothing surfaced that. The one caller — completing the last stage of a PSA-linked workflow execution in `processes/views.py` — discarded the return value and wrote an audit entry reading "completed execution and updated PSA ticket T-1234" either way. The audit trail asserted a note had landed on the ticket every time, and the tech was never told otherwise. Three parts to the fix: - **Note posting moved into the provider layer.** The manager was carrying its own copy of each vendor's auth and its own `requests.post` calls. The provider classes already hold that, correctly, along with HaloPSA's OAuth token exchange, a session with retry/backoff, and `_validate_base_url` — the SSRF guard every other outbound integration call passes through and these did not. `add_ticket_note` is now part of the provider interface, gated by a `supports_ticket_notes` flag, and the manager is a dispatcher. - **Autotask and HaloPSA notes implemented.** Both were `# TODO` stubs behind the dead credential check, though both are otherwise fully supported PSAs. Autotask posts to the ticket's `Notes` child collection; HaloPSA posts an action to `/api/Actions`. Where a provider's visibility flag could be wrong for a given tenant, it is wrong in the safe direction: `internal=True` maps to the value that keeps the note off the client portal, and the Autotask `publish` values are named constants rather than inline integers so that stays reviewable. Syncro's note call also moves to the documented `/tickets/{id}/comment` endpoint and sets `do_not_email` on internal notes — an internal note that mails the customer a copy is not internal. - **The caller reads the result.** The audit entry records what happened and carries `psa_note_posted` in `new_value`; a failure says "was NOT posted" with the reason. The stage still completes — a workflow that finishes without its PSA note has still finished — but the response carries `psa_note_error` and both UIs that complete stages say so rather than reloading clean. A provider with no note API (Zendesk, Freshservice, Kaseya BMS, RangerMSP, Alga) now reports `unsupported` distinctly from a failure, so the audit entry names the real reason. Those five remain unimplemented. The vendor endpoints follow each PSA's documented ticket-note API; they could not be exercised against live tenants here, and the tests assert the request each provider builds rather than a round trip. 24 new tests across `integrations/tests_psa_notes.py` and `processes/tests.py`. ## [3.17.566] - 2026-09-16 ### SSL and domain expiry checks now send the notification they promised Both scheduled tasks ended at a literal `# TODO: Send email notifications`. They counted what was expiring, wrote the count to the scheduler log, and returned success. Both are enabled by default, so Settings > Scheduler has been showing a healthy green check for two notifications that have never been sent once since they were written. The counting halves were also wrong in three ways that carried straight into the sending version if left alone: - **Already-expired items were filtered out.** Both queries ended `expires_at__gte=now`, so the one state that actually takes a site down — a certificate or registration that has lapsed — produced no notification at all. Expiry is now its own escalated phase, with `EXPIRED` in the subject line. - **Per-item warning windows were ignored.** `WebsiteMonitor.ssl_warning_days` and `domain_warning_days`, and `Expiration.warning_days`, are all on the edit forms and all defaulted to the global setting instead. The wider of the item's own window and the global one now applies, so neither setting warns later than it says it will. - **Per-monitor opt-outs were ignored.** `notify_on_ssl_expiry` and `notify_on_domain_expiry` on the monitor did nothing. Domain checking also looked only at manually entered `Expiration` rows, ignoring `WebsiteMonitor.domain_expires_at` — writable through the monitor form and the API. Both sources are now read. (Nothing populates that column automatically; there is no WHOIS collector yet.) Re-notification is keyed, not flagged: the stored key is `":"`, so a renewed certificate re-arms its own warning without anything having to reset a flag, and crossing from warning into expired re-arms it once more for the escalation. A daily task with a plain "sent" boolean would have gone quiet for good after its first send. ### Fixed: the vault password expiry email would have crashed on its first real send Its recipient query filtered on `organization_memberships`, a reverse accessor no model defines — `accounts.Membership` sets `memberships`. The query could only ever have raised `FieldError`. It had not been reached in production because the task returns early when no vault password carries an expiry date, so the failure was waiting on the first one that did. Inactive memberships are excluded now too. ### Shared notification plumbing `core/mailer.py` holds the four things every notifying task needs: an SMTP connection built from the encrypted `SystemSetting` password, a From address, an organization's recipient list, and a send loop where one rejected address does not take the rest of the notification with it. Writing that out a fourth time is a good part of why the two expiry tasks stopped at a TODO. The two copies inside `run_scheduler.py` (vault password expiry, system warnings digest) now use it; the copies in `scheduling/` and `run_security_scan` are untouched for now. Covered by `monitoring/tests_expiry_notifications.py` (17 tests). 393 green across monitoring, core, vault, accounts, scheduling and api. ## [3.17.565] - 2026-09-16 ### A killed scheduler process no longer takes a task off the schedule forever `ScheduledTask` took its run lock by writing `last_status='running'` and cleared it only after the work returned. Nothing else in the codebase ever cleared it: no reaper, and no reset button on Settings > Scheduler. So any death between those two writes left the row at `running` permanently, and `should_run()` returned `False` permanently after — the task silently stopped running, its `next_run_at` receding into the past, and the only recovery was editing the database by hand. The deaths are ordinary, not exotic: a reboot, an OOM kill, a `systemctl stop` while the nightly breach scan is halfway through the vault. This host rebooted today at 17:25; had the scheduler been mid-task, that task would be dead now. The irony is that the scheduler already ships `cleanup_stuck_scans` — a task whose entire job is to reap security scans stuck in `running` for more than two hours. The scheduler had no such protection for itself. Two changes: - **A stale claim expires.** A run still flagged `running` more than `STALE_RUN_MINUTES` (6 hours) after it started is treated as abandoned and is re-claimable. Six hours is comfortably above the slowest shipped task — the security scan's own stuck-scan cleanup gives Snyk two. `should_run()` returns `True` for a stale run directly rather than falling through to the `next_run_at` comparison, because a task interrupted on its *first* execution has no `next_run_at` at all: that was a second, independent way to be stuck. - **The lock is taken with a conditional UPDATE.** The timer fires every minute and several tasks run longer than that, so two scheduler processes can hold the same row, both having decided it is due. `claim()` now returns `True` only to the process whose UPDATE actually matched a row; the loser skips the task instead of running it a second time. Previously `mark_started()` wrote unconditionally and both would have run. The scheduler reports a reclaim rather than performing it quietly — the run that was interrupted is worth knowing about — and `mark_started()` is gone, since leaving a second, non-atomic way to take the lock invites its reuse. `last_run_at` was documented as "Last successful execution time" while being written at the *start* of a run. The reaper measures the age of a claim from it, so the help text now says what the field holds. Covered by `core/tests/test_scheduler_stale_lock.py`. ## [3.17.564] - 2026-09-16 ### A billing period is invoiced once `Contract.generate_invoice` created an Invoice unconditionally. The daily `psa_generate_recurring_invoices` command claimed idempotency in its own docstring — "already-billed periods don't get re-billed because the cron advances the date" — but the invoice and that cursor advance were two separate writes with no transaction around them. Anything that interrupted a run between them left the invoice committed and `next_billing_date` unmoved, so the next day's run billed the customer again. A manual re-run duplicated with no interruption needed at all. Measured against the unfixed code: three calls for one September period produced three invoices and billed 7,500.00 against a 2,500.00 contract. `Project.generate_invoice` has guarded against exactly this since it was written (`already_fixed_fee_invoiced`). The contract path — the one on a daily timer — had nothing. Three layers, because the first two can both be walked through: - **A unique constraint** on `(source_contract, billing_period_start)`, via a new `Invoice.billing_period_start` column. This is the layer two concurrent workers cannot both pass. Credit memos are excluded: crediting a period is a legitimate second document for that period. - **An application guard** — `generate_invoice` returns the existing invoice for the period rather than creating or raising, so a caller's retry path reports the invoice it already made. - **An atomic cursor advance** — the invoice and `next_billing_date` commit together. An `IntegrityError` from a concurrent run advances the cursor rather than looping. The optional accounting auto-push stays deliberately outside the transaction: a push to QuickBooks or Xero is not something a rollback can undo, so it must not be able to roll back the invoice either. Also fixed a latent race in `Invoice._next_number`, which reads the highest existing number and adds one. Two workers reach the same answer and, since `invoice_number` is unique, the loser surfaced a raw IntegrityError to whoever clicked the button. It now retries on a savepoint — a savepoint specifically because this runs inside the generator's transaction, where a bare IntegrityError would poison the whole block. **Migration `psa.0068` is additive.** Every existing invoice gets `billing_period_start = NULL`, and nulls are distinct in a unique index, so no existing row can collide with another. A test pins that manual invoices, which carry no period, remain unaffected. Seven tests in `psa/tests/test_invoice_duplicates.py`, including the crashed-run sequence — an invoice committed with the cursor left behind, which is exactly what the following day's run encounters. ## [3.17.563] - 2026-09-15 ### SLA: the clock pauses, and breaches are recorded Two defects from the Phase 49.2 audit, both in SLA calculation. **The SLA breach report always returned zero.** `Ticket.sla_breached_response` and `sla_breached_resolution` are declared fields that no production code ever set. `PSASLABreachesReport` filters on them and the `sla_breach_count_30d` KPI counts them, so both reported no breaches however many there had been. Meanwhile `psa.sla.resolution_breached()` computed the truth live for the ticket badge. The badge said breached; the report an MSP would show a client said there were none. New `psa.sla.refresh_breach_flags()` persists the live computation. It clears as well as sets, so extending a deadline or reopening a ticket does not leave a breach recorded against it — but only where there is a deadline to judge against. With no due-date the question "did this breach?" has no answer, and "no answer" must not be written down as "no": clearing a breach needs evidence it did not happen, not an absence of evidence that it did. The first version of this did clear flags on tickets with no SLA target, which would have erased breaches recorded by an import or integration; `reports.tests.KPIDashboardTests` caught it. It runs on save and from the existing five-minute `psa_sla_workflow_tick` — the second matters, because a ticket that simply runs out of time is never saved and nothing else would notice. **Pausing the clock never moved the deadline.** `psa/sla.py` said in its own module docstring that "we extend the due-date by the pause duration on resume". Nothing did. `Ticket.sla_paused_until` was declared and never written by any code. A ticket parked in Waiting on Client for three days came back with its original deadline and breached immediately, through no fault of the technician. Entering a status with `pauses_sla` now stamps `sla_paused_at`; leaving it adds the elapsed time to `sla_paused_minutes` and pushes both due-dates out by the same amount. The deadlines move, rather than the elapsed time being subtracted at comparison time, so the date shown in the UI, written to an export and read by a workflow rule are the same date. This is hooked into `post_save` rather than the ticket detail view, because status changes also arrive from the mobile API, the inbound email ingester, workflow actions and bulk imports. A pause that only counted when someone clicked the status dropdown would be worse than none. The sync is wrapped and logged: SLA bookkeeping must never stop a ticket being saved. The module docstring claiming the behaviour already existed has been corrected. It is plausibly why this went unnoticed. **Migration `psa.0067` is additive** — two new columns, nothing dropped. `sla_paused_until` stays in place, marked deprecated. Removing a column is not something a cleanup pass should do to a customer's database. **Expect the breach numbers to change.** Once this is live the SLA breach report stops reporting zero, and the first tick will flag historical tickets still open past their deadline. If that report has been read as "no breaches", the real figure is about to appear. Sixteen tests in `psa/tests/test_sla_calculation.py`, three of them pinning that a recorded breach survives a refresh it cannot evaluate. Against the old logic four fail behaviourally — pause accounting and the tick — and six raise ImportError for a function that does not exist there, which proves nothing on its own; the breach-reporting half was demonstrated directly instead, by showing `resolution_breached()` returning True, the stored flag False, and the report returning zero rows for the same ticket. 911 tests green across psa and reports. ## [3.17.562] - 2026-09-15 ### The update progress bar no longer hangs on "Restart Service" Reported from a real update: the page sat on Restart Service indefinitely. The update had finished. The server was already running the new version. What broke was the progress bar reporting on itself. An update's last act is reloading gunicorn, which kills the process writing the progress file. `set_progress` used `open(path, 'w')` — truncate immediately, then stream the JSON out in buffered chunks — and the reload landed inside that write. The file left behind was 8,195 bytes ending mid-key at `"level"`, while beginning `{"status": "completed", ...}` with all five steps listed. The writer had the right answer and was killed one buffer flush in. `get_progress` then caught the `JSONDecodeError` and returned `status: 'idle'`. The front-end reads `idle` as "no update is running", so it kept redrawing the last step it had seen, with no way to tell a finished update from one that never started. - **Writes are atomic.** A temp file in the same directory, fsynced, then `os.replace()` over the target. A reader sees the whole old file or the whole new one, never half of either. A test writes 60 times and parses the file after every one. - **Logs are capped at 400 lines.** They were unbounded, and every line rewrites the whole file, so the file grew until a partial write was likely rather than unlucky. That is why this surfaced now rather than on earlier updates, and it also removes the O(n^2) rewriting. - **An unreadable file reports `unknown`, not `idle`.** The front-end stops polling, says plainly that it could not confirm the result, and reloads so the version on screen answers the question. A six-minute ceiling on the poller means no future failure of this shape can spin forever either. Seven tests in `core/tests/test_update_progress.py`, one built from the exact byte pattern the production file had. The behaviour was also confirmed directly against that file: the old code reports `idle`, the new code reports `unknown`. Nothing about how an update is performed changed — only how its progress is recorded and read. ## [3.17.561] - 2026-09-15 ### Tax is charged on the taxable lines `is_taxable` has existed on `InvoiceLineItem` and `QuoteLineItem` since those models were written. It defaults to True, is exposed in the UI, is copied through credit memos, and no tax calculation ever read it. Both `recompute_totals()` methods computed tax from the entire subtotal. Any invoice mixing taxable goods with non-taxable labour or reimbursed expenses overcharged the customer. On a 2,000 labour + 900 hardware + 180 travel invoice at 8.25%, the tax charged was 254.10 against 74.25 actually due — 179.85 too much, every billing cycle. `create_credit_memo(amount=...)` made the disagreement explicit: it writes `is_taxable=False` on the credit line, and the old calculation taxed it anyway. A 500.00 goodwill credit went out worth 541.25. Tax now accumulates over taxable lines only, in both methods. Rounding moved to ROUND_HALF_UP at the same time — Decimal defaults to banker's rounding, which disagrees with the accounting providers by a cent on exact half-cent results (10.00 at 8.25% is 0.825 exactly, charged as 0.82). That is the drift `provider_tax_amount` was added to detect. Seven tests in `psa/tests/test_invoice_tax.py`; four failed against the unfixed code with exactly the numbers above, and the other three are regression guards for the all-taxable, zero-rate and full-credit-memo cases that were already correct. 998 tests green across psa, reports and integrations. **Existing invoices are not rewritten.** Stored totals stay as issued until something recomputes them — an edit, a payment, or a re-push to the accounting provider — at which point the corrected figure applies. That is deliberate: silently restating issued invoices is not a code decision. ### A read-only report of affected invoices New `manage.py psa_tax_audit` lists invoices whose stored tax disagrees with the corrected calculation, with a per-client breakdown and a net total. manage.py psa_tax_audit manage.py psa_tax_audit --org acme --since 2026-01-01 --csv audit.csv Drafts and voids are excluded by default — a draft was never sent and a void was withdrawn — as are credit memos, so credits carrying the same defect do not net off the overcharge total. `--all-statuses` and `--include-credit-memos` opt back in. It modifies nothing, and a test asserts that: subtotal, tax_amount, total, status and amount_paid are unchanged across a run. The output states in plain terms that a hand-edited tax amount or a rate changed after issue also lands in the list, and that nothing in it constitutes a refund decision. Six tests in `psa/tests/test_tax_audit.py`. They caught a crash before real data would have: `iterator()` after `prefetch_related()` raises unless given an explicit `chunk_size`. ## [3.17.560] - 2026-09-15 ### Backups: encrypted by default, and one key-normalisation path Two findings from the Phase 49.2 audit, both in backup and restore. **`manage.py backup` wrote the database in the clear, into /tmp.** The command described itself as producing encrypted backups, but `--encrypt` was `store_true` with no default, so encryption was opt-in and off. The default output directory was `/tmp/clientst0r-backups`, created at 0755 under a normal umask. The archive holds the whole database — every ticket, document and customer record. Vault secrets stay AES-GCM encrypted inside it and were never exposed; nothing else was protected. - `--encrypt` now defaults on. `--no-encrypt` opts out and warns, naming what the archive contains rather than just saying "unencrypted". - Default output moved to `BASE_DIR/backups`. `/tmp` is world-readable *and* swept by tmpfiles, so a backup left there could quietly disappear. - The output directory is chmod 0700 and the finished archive 0600. **A master key the application accepted could fail at restore.** Backup and restore built their Fernet from `settings.APP_MASTER_KEY.encode()` directly, handing Fernet the raw configured string. The rest of the vault normalises the key first — whitespace, URL-safe alphabet, padding. An unpadded key therefore worked everywhere in the application and raised `ValueError: Fernet key must be 32 url-safe base64-encoded bytes` inside restore. Recovery is the worst place to discover a key-handling difference. Both commands now route through the new `vault.encryption.get_fernet()`. This is backward compatible by construction rather than by hope: both the old and new paths end at the same 32 raw bytes for every key the old path accepted, and a test asserts that archives written by the previous code still decrypt. **Behaviour changes.** Nothing in the repository invokes `backup` — there is no systemd timer or scheduler task for it — so this reaches anything you run by hand. A script reading `/tmp/clientst0r-backups` will no longer find files there, and one relying on the unencrypted default now receives a `.enc` archive unless it passes `--no-encrypt`. Nine tests in `core/tests/test_backup_restore.py`, covering the argument defaults, all four master-key shapes, and old-format compatibility. The encryption-default and /tmp-default failures were confirmed against the unfixed code; the key-shape defect was demonstrated directly, by building a Fernet the old way from an unpadded key that the vault accepts. Still open in this area, not addressed here: RMM and integration credentials use the v1 encryption layer, which has no AAD context binding or key-rotation version tag, while vault passwords use v2, which has both. Re-encrypting live credentials is a migration and belongs in its own release. There is also no key rotation command, which is why v2's version tag currently drives nothing. ## [3.17.559] - 2026-09-15 ### Interface consistency, tenant-boundary fixes, and a rebuilt GitHub presentation A catch-up release. The work below landed across fourteen commits since v3.17.558 without a version bump — that was a mistake, because Settings → Updates had nothing to detect and the running server stayed on .558. This entry covers all of it; there is one tag rather than fourteen because the commits are already pushed and retagging them would mean rewriting history. **What actually changes in the running application:** the UI pass, two bug fixes, and four security fixes. The README, screenshot gallery and walkthrough video are GitHub-side only and will not look any different once you apply this. #### Shared UI layer - New `static/css/ui.css` defines the scale once — type sizes, control height, row height, spacing steps, corner radius — and reads colour from whichever of the twelve themes is active. No theme was replaced or overridden. - **Removed the global shrink.** `custom.css` had been setting `html` to 14.5px and `body` to 0.92rem, which scaled the whole application down instead of sizing its parts. Sizes are now stated in pixels, once. - Application header: the navigation wants ~1855px and the common viewport is 1440px. A measured window between 1400px and 1750px drops the clock, the nav-link icons and some padding so it stays on one row. Nothing was removed from the menu. - Page headers, filter bars, tables, badges, empty states, detail grids and dashboard tiles standardised against the tokens. Documents, ticket lists and the dashboards got the most attention. Checked for horizontal overflow at 1440×900, 1920×1080, 1024×768 and 390px — none at any width. - Dashboard tile labels were unreadable on the dark themes: `themes.css` paints every `small` with `--text-secondary` and `!important`, which sat too low against the tile. They now lift partway toward the body text — enough to read, not so far that the label competes with the number it labels. - Ticket lists no longer guess urgency from a priority's code string. New `psa_ui` filters rank `TicketPriority` by its configured `sort_order`, and read a status's tone from `is_terminal` / `pauses_sla`. Neither model has a colour field, so nothing pretends to read one. #### Tenant boundaries Four views fetched a record by id and checked only whether the user held the relevant permission flag. `user_has_perm()` returns True when a role template sets a flag on *any* active membership — it answers "may this user do this kind of thing", not "may they do it to this record". - `resourcing` holiday list, edit and delete were reachable across tenants. The list also leaked other organizations' holidays. Shared national holidays (those with no organization) still appear, but can no longer be edited by a tenant user. `HolidayForm` narrows its organization choices *and* re-validates the submitted value on the server — a narrowed `` is trivially bypassed by a hand-rolled POST, so anything that is not `#rrggbb` is rejected. The policy and preset values are likewise checked against their choice lists. - **Image mode with nothing uploaded yet** falls back to no background rather than a broken one. The admin has picked the mode but not finished. **Cleanup along the way.** The twelve preset backgrounds were defined twice — the labels as inline choices on the profile model, the URLs as a dict inside a context processor — with nothing tying them together. A key present in one and missing from the other would have shown up only as a silently absent background. They are now one table in `core/backgrounds.py` feeding all three consumers. Note this is unrelated to the GeoIP map backdrop from v3.17.522, which styles the world map on the firewall and vault access-rule screens. This one is the app page background. ### Fix: two tests from v3.17.526 were wrong The double-push guard shipped correct and its two behavioural tests passed, but two supporting tests never created an `AccountingConnection`. The view therefore exited at "No active accounting connection" before reaching the provider, so asserting the provider had been called proved nothing and failed. Both now build a connection first. The guard itself was not changed. ## [3.17.526] - 2026-09-03 ### Fix: a double-click could put two invoices in front of a client `push_invoice()` creates a **new** invoice in QuickBooks Online or Xero on every call. Nothing stopped a second call. A double-click, a browser resend, or an impatient second click on a slow push therefore raised a duplicate invoice against the client's accounts payable. Deduplication existed, but only as detection — the reconciliation report found duplicates *after* they were already in the accounting system, where correcting them is somebody's phone call. The push view now refuses an invoice that already carries an `accounting_external_id`, and says which provider and id it already has so the operator can go look. Re-push is still possible, because an invoice genuinely deleted provider-side needs it — but it is now an explicit "Re-push" button with its own confirmation, rather than the same button as the first push. The check runs before connection resolution: already-pushed is true regardless of which connection would be chosen. ### Fix: the roadmap claimed an accounting sync that has never run `docs/ROADMAP.md` marked Phase 27's bidirectional payment sync as shipped "with a daily cron". There is no cron. There is no systemd timer, no crontab entry, and no `run_scheduler` task type for `accounting_sync_payments` — a repo-wide search finds it referenced only by the changelog, the roadmap, its own tests, and itself. The command has only ever run when a human typed it. The claim traces to the command's own module docstring, which ended with "Wire to a daily systemd timer alongside the existing accounting jobs" — a to-do that was read as a description. Nobody wired it, and the roadmap wrote it up as done. Corrected in three places, because the roadmap is published on four surfaces including a JSON feed that external dashboards poll: - The roadmap bullet now reads *partial*, states plainly that nothing schedules the command, and notes the second overstatement: it polls the invoice **balance** rather than reading provider payment records, so a partial payment is invisible (a non-zero balance is skipped) and the `Payment` row it writes carries an inferred date and `method='other'` rather than the real ones. - The command's docstring now leads with **NOT SCHEDULED** and explains what it does and does not do. The note it writes into `Payment.notes` — which is user-visible — no longer calls itself a cron and now says the amount and date are inferred. - `BaseAccountingProvider.poll_invoice_balance()`'s docstring likewise. ### Added: Phase 44 — Full Two-Way Accounting Sync (planned) GitHub #145 asks for full two-way QuickBooks Online sync. An audit of what exists found the push half is solid — OAuth with encrypted tokens and automatic refresh, invoice push with GL-account and tax capture, multi-book routing, and an audit log of every provider call — and that essentially none of the pull half exists. Rather than leave the gap implicit, it is now a roadmap phase enumerating each missing piece: customer mapping as a real model rather than a dict inside the encrypted credentials blob, customer pull, invoice pull beyond `Balance`, real payment pull with partial-payment and reversal support, scheduling, a manual "Sync Now", retry with backoff, and three logging gaps (`AccountingConnection.last_sync_at` and friends are declared but never written, so the UI always shows "Never"). ## [3.17.525] - 2026-09-03 ### Fix: developer comments were being displayed to users on ten pages A comment on the dashboard was showing up as literal text on the page. The cause is a Django rule that is easy to miss: **`{# ... #}` is a single-line comment only**. Spread one across two lines and it stops being a comment — Django renders it verbatim into the page, with no error and no warning. The note you wrote for the next developer is simply shown to every user. The dashboard one arrived in v3.17.524 and was reported immediately, but a sweep of `templates/` found it was not new: **ten templates** were doing this, some for many releases. All are converted to `{% comment %}...{% endcomment %}`, which is the multi-line form: - `core/dashboard.html` — the v3.17.524 panel note (reported) - `core/system_updates.html` — printed a rationale about exactly which sudo commands the updater is granted, onto the Settings > Updates page - `core/mobile_apps_admin.html`, `portal/ticket_detail.html`, `psa/client_settings.html`, `psa/ticket_create.html`, `psa/_link_to_problem_form.html`, `psa/_scope_banner.html`, `reports/crm_sales_funnel.html`, `vault/access_rule_form.html` None of the ten contained template tags inside the comment text, so nothing was executing that now stops executing — the change removes the leaked prose and nothing else. **Guarded two ways.** `TemplateCommentTests` in `core/tests/test_source_syntax.py` sweeps every shipped template and fails on any unterminated `{#`, so this cannot come back anywhere in the codebase. The dashboard render test additionally asserts no raw template syntax survives into the response for the page it happened on. ## [3.17.524] - 2026-09-03 ### Feature: the dashboard now shows what needs doing, not what already happened Three of the home dashboard's panels looked backwards. **My Recent** and **Recent Activity** were both audit-log tails, and **Expiring Soon** sat in a corner on its own. Between them they filled two thirds of the page with history. They are replaced by two panels about the near future. **Schedule (next 7 days)** — scheduled tasks and ticket resolution deadlines on one timeline, grouped by day. Days with nothing on them are omitted rather than rendered as empty rows; an agenda of seven blanks tells the reader nothing. Each row links straight to the task or ticket. **Tasks** — open work ordered by due date, most urgent first, with undated items last so the top of the list is always the thing that matters rather than the thing created most recently. Overdue rows carry a red badge; anything due inside a week is amber. **Expiring items are now tasks.** An expiring password, a tracked expiration or an SSL certificate about to lapse is something a person has to act on — which is what a task is. All three are folded into the Tasks panel with their own icons instead of occupying a separate card. The 30-day look-ahead is unchanged, but the window now extends 30 days *backwards* too, so something that lapsed last week shows up with an Overdue badge instead of vanishing the moment it matters most. It is bounded rather than open-ended on purpose: these rows sort earliest-first and an expiry has no lifecycle a human closes, so a single record that lapsed years ago would otherwise sit at the top of the panel forever. The panel shapes mirror the mobile agenda added in v3.17.478, so the two surfaces agree about what "upcoming" means. **Under the hood.** New `core/dashboard_panels.py` holds the query logic — `get_schedule()` and `get_tasks()` — rather than growing `dashboard_views.py` further. The view is net smaller: the two audit-log queries behind the deleted panels and the three `expiring_*` querysets are gone, because `get_tasks()` covers the expiring items and a repo-wide search confirmed nothing else read any of those five context keys. Five fewer queries per dashboard load. The dashboard view had no test at all, so a template still referencing a dropped context key would only have surfaced in production. `DashboardRenderTests` now renders the page end-to-end and asserts both the new panels and the absence of the old ones. Three things the tests pin, all learned the hard way while building it: - **Every link is built with `reverse()`, never a hand-written path.** The first cut wrote five paths by hand and three were wrong — tickets live at `/psa/t//` not `/psa/tickets//`, expirations are under `monitoring/` not `core/`, and bare `/monitoring/` has no route at all. A dead href renders exactly like a live one, so nothing looked broken. `PanelUrlTests` now resolves every URL each panel can emit, across all four row kinds. - **Only `ImportError` / `LookupError` may be caught.** PSA and monitoring are optional in some deployments, so their imports are guarded — but a broad `except Exception` there once swallowed an `AttributeError` (`Password.name`; the field is `title`) and the panel simply rendered empty. A test walks the module's AST and fails on any broader handler. It checks the AST rather than grepping, because a string search matches this module's own prose explaining the rule. - **Order before slicing.** Every panel queryset takes a `[:N]` slice, and three of them relied on the model's default ordering — which is `title` for `Password` and `name` for `WebsiteMonitor`. The slice was therefore taking the alphabetically-first ten rows rather than the ten expiring soonest, quietly hiding the urgent ones. Open tasks had the mirror-image problem: SQLite sorts NULLs first ascending, so undated tasks filled the slice ahead of dated ones. All four querysets now order explicitly (`nulls_last=True` for due dates), with a test for each direction. ## [3.17.523] - 2026-09-03 ### Feature: spreadsheet import for Shop and VAN inventory Stock levels had to be entered by hand. The import pipeline that already backed assets, passwords, contacts and documents — with column mapping, preview, job history and rollback — now covers inventory too, and reads spreadsheets as well as CSV. **Two new targets** on the existing importer: - **Shop Inventory** → `vehicles.ShopInventoryItem` - **VAN Inventory** → `vehicles.VehicleInventoryItem` They are near-identical sibling models, so they share one field-mapping path; the vehicle one additionally resolves which van each row belongs to. **Spreadsheet support.** A new `imports/services/tabular.py` reads `.xlsx`/`.xlsm` via openpyxl and CSV via the stdlib, returning the same `(headers, rows)` shape either way — so a spreadsheet is indistinguishable from a CSV to everything downstream, and the existing CSV path now goes through the same reader rather than a second inline `DictReader`. There is a test asserting the two formats produce byte-identical results. Excel's typing needed care: it stores every number as a float, so a quantity of `12` arrives as `12.0`, and `"12.0"` fails an `IntegerField` parse. Integral floats are normalised back to `12`. Blank rows and trailing empty header columns — both normal in hand-edited sheets — are dropped. **Legacy `.xls` is refused**, with an instruction to re-save as `.xlsx`. openpyxl does not read the 1997 binary format and xlrd dropped it in 2.0; pulling in an unmaintained pin to half-parse it would be worse than a clear error. **Vehicle matching** accepts the name, licence plate or VIN — whatever is actually printed on the van — because requiring a database id in a spreadsheet is how an import feature goes unused. An unmatched vehicle skips that row and logs the value that failed to match, so the operator can fix the sheet and re-run, rather than losing the whole batch. Junk in a numeric cell falls back to the field default instead of aborting the import. ### Fixed while building it Three wrong assumptions, each caught by running the code rather than reading it: `ServiceVehicle` has no `unit_number` field, and neither the fleet nor either inventory model is org-scoped — so the `organization=` kwarg I first wrote would have raised on every row. ### Added - `imports/tests_inventory_import.py` — 15 tests covering the reader (CSV/xlsx equivalence, float normalisation, blank rows, trailing headers, `.xls` refusal, preview limits) and the importers (item creation, name required, junk quantities, vehicle matching by all three identifiers, unmatched-vehicle logging, target registration). - `openpyxl>=3.1,<4.0` in requirements.txt. ## [3.17.522] - 2026-09-03 ### Feature: click-to-select world map for GeoIP country selection Picking countries meant typing ISO codes into a text box — `US, CA, GB` on the vault access-rule form, and a code-plus-name form on the global firewall rules page. A world map replaces both. **Where it appears.** The same component (`core/_geoip_map.html`) is used on: - **Settings → Firewall → Country Rules** — one list; a country either has a rule or it does not. Whether that means blocked or allowed is the firewall's own mode, shown in the card header so the map is never ambiguous about what selecting a country will do. Saving reconciles the rule list against the selection. - **Vault → Access Rules** — two lists, Allowed and Blocked, with a mode switcher. A country can only be in one, so an "allowed AND blocked" contradiction is not expressible on the map. **It is an input method, not a new storage format.** The map writes comma-separated codes back into the same inputs the form already posted, so the server contract is unchanged, the manual add form still works, and the page degrades to the original text fields with JavaScript off. **Country names are resolved server-side.** These rows drive a firewall and are displayed back to admins, so the name is never taken from the browser. `core/iso3166.py` holds a 249-entry ISO 3166-1 table generated from the Debian `iso-codes` package by `scripts/gen_iso3166.py` (verified to reproduce the committed file byte for byte). Unknown codes are dropped rather than stored — a typo becoming a rule that silently matches nothing is worse than losing it at the door. **Backdrop** is configurable per the request: a colour pattern (six presets), random (a different pattern each visit, chosen server-side so it is stable for the life of the page), or an uploaded image. Purely cosmetic, and saved through its own endpoint so a backdrop change can never touch firewall rules — there is a test asserting exactly that. Image mode falls back to the chosen pattern when the file is missing rather than rendering an empty box; uploads are type- and size-checked. jsVectorMap (MIT) is loaded from jsDelivr with a pinned version, consistent with how this app already loads Bootstrap, Font Awesome and DataTables. ### Fixed while building it The firewall page 500'd on first render: the GeoIP allowlist/blocklist mode lives on `FirewallSettings`, not `SystemSetting`, and I read it off the wrong model. The tests written up to that point only POSTed to the endpoint and never fetched the page, so they all passed while the page was broken. Two render tests now cover it. ### Added - `core/tests/test_geoip_map.py` — 23 tests: the ISO table, code normalisation (case, dedupe, invalid-code rejection), backdrop resolution including the missing-image fallback, rule reconciliation from the map, the manual add form still working, superuser enforcement, and the backdrop endpoint leaving rules untouched. ## [3.17.521] - 2026-09-03 ### Fix: false positive in the deploy-asset test A full run of `core docs api api_mobile psa` came back **836/837**, with one failure — in a test added in v3.17.516, not in shipped code: ``` setup_mobile_build.sh copies deploy/mobile-build-sudoers, which does not exist ``` No such file exists and nothing copies one. `test_sudoers_templates_referenced_by_scripts_exist` scans deploy scripts for `*-sudoers` references with `([A-Za-z0-9_-]+-sudoers)\b`, and `\b` is satisfied by a following `.` — so when v3.17.519 introduced the install stamp `mobile-build-sudoers.sha256`, the regex matched `mobile-build-sudoers` inside that filename and demanded a template nothing had ever referenced. The pattern now ends with a `(?![\w.])` lookahead, so it only matches a name that genuinely *ends* at `-sudoers`. It resolves the two real references (`clientst0r-mobile-build-sudoers` and `clientst0r-fail2ban-sudoers`), and still fails when a referenced template is actually removed — verified both ways. Worth noting for its own sake: two changes of mine, four releases apart, combined into a failure neither would have caused alone. The test only earns its keep if it fails for real reasons, so a false positive in it is worth the same care as a bug in the product. ## [3.17.520] - 2026-09-03 ### Fix: spurious "One-Time Setup Required" banner on Settings → Updates Regression from v3.17.518. `Settings → Updates` began showing a *"One-Time Setup Required for Web-Based Updates"* banner on a host where one-click updates worked perfectly well. `UpdateService._check_passwordless_sudo()` led with `sudo -n systemd-run --version` — on the stated assumption that systemd-run is *"always present in the sudoers config"*. That held only while systemd-run was granted **bare**. The least-privilege ruleset pins it to the single restart invocation the updater issues, so `--version` is refused. And the check did: ```python if 'password is required' in result.stderr: return False # <- never reached the fallback probe ``` The `systemctl status` probe immediately below it would have succeeded. One denied probe was being treated as a verdict on all of them. Now it probes `systemctl status ` **first** — read-only, harmless, and much closer to what the updater actually needs — keeps systemd-run only as a fallback for older sudoers files that granted it bare, and continues past a denial instead of concluding from it. A non-zero exit from `systemctl status` (an inactive unit) still counts as configured, because it proves sudo permitted the command. ### Security: the banner was also telling people to grant far too much The command it asked operators to paste granted `/usr/bin/systemd-run`, `/usr/bin/tee`, `/usr/bin/pkill`, `/usr/bin/cp` and `/usr/bin/chmod` **bare** — with no arguments pinned. Any one of those is root in all but name: systemd-run runs an arbitrary command as root, and tee or cp will write any file they are pointed at, `/etc/sudoers` included. Anyone following that banner was handing the web app full root. Both the banner (`templates/core/system_updates.html`) and the equivalent hint in `core/updater.py` now show the same narrow set `scripts/install_auto_update.sh` installs — one line per command the updater actually runs — with a note that this deliberately does not grant general root access. ### Added - `core/tests/test_updater.py::PasswordlessSudoCheckTests` — four cases covering the exact regression (systemd-run denied, systemctl allowed), an inactive unit still counting as configured, all probes denied, and `not allowed to execute` treated as a denial. ## [3.17.519] - 2026-09-02 ### Fix: mobile build setup no longer needs sudo on every update Direct fallout from v3.17.518. With blanket `NOPASSWD:ALL` removed, `deploy/setup_mobile_build.sh` started failing again — it does `sudo cp` into `/etc/sudoers.d/`, which is deliberately **not** in the least-privilege ruleset. The `[WARN] Mobile build setup failed` line came back, for an entirely different reason than the one fixed in v3.17.516: ``` sudo: a terminal is required to read the password... sudo: a password is required [WARN] Mobile build setup failed (non-critical) ``` The step ran unconditionally on every update, re-copying a file whose content changes only when the template does. It now: - **Skips when nothing changed.** `/etc/sudoers.d` is root-only, so the installed file cannot be read — or even stat'ed — to compare against. The script therefore records the hash of what it last installed in `~/.cache/clientst0r/mobile-build-sudoers.sha256` and skips when the template still hashes the same. - **Exits 0 with guidance when the copy genuinely cannot be done**, instead of failing. The grant is optional — it covers only the first-run Node.js bootstrap, and mobile builds work anywhere Node and npm are already installed. Failing every update adds noise, not information; the message says exactly which one-time command to run to enable it. Worth recording, because it cost a debugging cycle: the stamp compares `printf '%s' "$(sed ... file)"`, and command substitution strips trailing newlines — so a hash computed by piping the file directly into `sha256sum` will never match one computed the script's way. Both sides must use the same method. ## [3.17.518] - 2026-09-02 ### Security: least-privilege sudo that actually covers the app — prerequisite for dropping blanket NOPASSWD `/etc/sudoers.d/99-administrator-nopasswd` grants `ALL=(ALL) NOPASSWD:ALL`, which is why every sudoers mismatch found over the last few releases went unnoticed: nothing could fail. Removing it turns out to need work first, because the scoped rules did **not** cover everything the running application issues. Six call sites would have broken silently. Audited every `sudo` invocation across `core/`, `deploy/` and `scripts/`, and split them by who initiates the command. **The running app** gets an explicit NOPASSWD grant — a gunicorn worker has no tty, so anything ungranted fails rather than prompting. **Admin-run bootstrap and repair scripts** are left to prompt like any other privileged manual action. Two mismatches were better fixed in the code than by loosening a rule: - `core/settings_views.py` restarted `clientst0r-gunicorn` **without the `.service` suffix**. sudoers matches arguments literally, so that could never have matched a scoped rule. Now names the unit in full. - `core/management/commands/auto_heal_version.py` used `systemd-run --on-active=2` where the granted rule pins `=3`. Normalised to `=3` so one rule covers both callers. Newly granted, each traced to its call site: - `pkill -9 -f gunicorn` — the force-restart path in `core/views.py` (stop → kill stragglers → clear bytecode → start). The SIGKILL sweep is the point of that path: it exists for a wedged worker a graceful stop has not cleared. - `apt-get -q update` and `apt-get -y -o Dpkg::Options::=--force-confdef -o Dpkg::Options::=--force-confold *` — patch management, reached from the UI via `core/security_views.py`. Applying system updates is the feature, so this necessarily permits installing from configured repositories; the option prefix is pinned to what the command builds. Deliberately **not** granted: `setup_package_scanner_sudo.py` (writes into `/etc/sudoers.d`) and `scripts/fix_gunicorn_env.sh` (`tee`s the unit file). Both are admin-run steps, not things the app does unattended. Verified: the generated file passes `visudo -c`, and a static coverage check of 15 app-initiated commands against the installed rules is **15/15**. Note for anyone planning the same cleanup: there are **two** blanket rules, not one — `99-administrator-nopasswd` and cloud-init's `90-cloud-init-users`, which carries an identical `NOPASSWD:ALL` and may be regenerated on boot. Removing one alone changes nothing. ## [3.17.517] - 2026-09-02 ### Security: clear all three Dependabot alerts (1 high, 2 moderate) | Severity | Package | CVE | Fixed in | |---|---|---|---| | **High** | cryptography | CVE-2026-69247 — Bleichenbacher oracle via distinguishable errors and timing in PKCS#7 `EnvelopedData` decryption | 50.0.0 | | Moderate | djangorestframework | CVE-2026-73228 — `request.data` could bypass Django's `DATA_UPLOAD_MAX_MEMORY_SIZE` on oversized JSON and urlencoded bodies | 3.17.2 | | Moderate | djangorestframework | CVE-2026-73229 — `AdminRenderer` may disclose GET-protected data when rendering invalid write requests | 3.17.2 | Pins moved to `cryptography>=50.0.0,<51.0.0` and `djangorestframework>=3.17.2,<4.0`, installing 50.0.1 and 3.18.0. **Exposure, since the three are not equal here.** CVE-2026-73228 is the one that actually reaches this codebase: `request.data` is used throughout `api/` and `api_mobile/`, so the upload-size cap was bypassable. CVE-2026-73229 is not reachable — `DEFAULT_RENDERER_CLASSES` is `JSONRenderer` only in production (`BrowsableAPIRenderer` is added only under `DEBUG`), and `AdminRenderer` is never configured. The high-severity cryptography issue is in PKCS#7 `EnvelopedData` decryption, which nothing in this codebase calls directly; it arrives transitively through Authlib, google-auth, joserfc and msal. Patched regardless — none of that is a reason to sit on a high. **msal had to move too.** `msal==1.34.*` requires `cryptography<49`, which would have pinned cryptography to the vulnerable range no matter what the direct requirement said — the upgrade failed `pip check` until this was resolved. msal 1.37 relaxes the constraint to `<51` (1.36 still caps at `<49`), so the pin is now `msal>=1.37,<2.0`, installing 1.38.0. This is a live dependency of the M365 Graph mail integration (issue #142), not an incidental one. **Verified:** `pip check` clean, and the full `api`, `api_mobile`, `psa`, `core` and `docs` suites — **833 tests** — pass against the upgraded set, covering both the DRF request paths and the M365 code that uses msal. Also removed a `~est_framework` directory pip left in `site-packages` when it could not finish replacing the old DRF — the same orphaned-backup artifact cleaned out of this venv earlier today. ## [3.17.516] - 2026-09-02 ### Fix: "[WARN] Mobile build setup failed" on every update — a template git refused to track Every update since v3.17.492 logged `[WARN] Mobile build setup failed (non-critical)`. The cause was three layers deep. `deploy/setup_mobile_build.sh` copies `deploy/clientst0r-mobile-build-sudoers` into `/etc/sudoers.d/`. **That template did not exist.** The v3.17.492 rename deleted the `huduglue-`named original, and the replacement was never committed — `.gitignore` carried a blanket `deploy/*-sudoers` rule, meant for generated output, so **git silently ignored the new template**. The script's only symptom was a bare `cp: cannot stat`, swallowed behind a generic warning. Nothing failed loudly, so nothing got looked at, for 23 releases. Fixed at all three layers: - **The template is restored**, and `.gitignore` now carries explicit `!` negations for the three shipped sudoers templates, so a real template can never again be un-addable while genuinely generated ones stay ignored. - **The script now says what is wrong** — naming the missing file and noting that mobile builds still work anywhere Node.js and npm are already installed — rather than emitting a bare `cp` error behind a generic warning. - **`core/tests/test_deploy_assets.py`** asserts that every `*-sudoers` a deploy script copies exists in the repo, and that no shipped template sits under a blanket ignore without a negation. Verified to fail when the template is removed. ### Security: the restored grants are narrower than the ones they replace The deleted file granted `NOPASSWD: /usr/bin/npm install *` and `NOPASSWD: /usr/bin/npm *`. Both are dropped. Nothing in the codebase has ever run npm under sudo, and `npm *` as root is root in all but name — npm executes arbitrary lifecycle scripts from any package it installs. What remains is exactly the four commands `core/management/commands/build_mobile_app.py` invokes for its first-run Node bootstrap (and only when `curl` or `npm` is absent): `apt-get update`, `apt-get install -y curl`, `apt-get install -y nodejs`, and `bash /tmp/nodesource_setup.sh`. The path was also corrected from `/bin/bash` to `/usr/bin/bash` — the old rule could never have matched the command it was written for. On this host the correctly-scoped `/etc/sudoers.d/clientst0r-mobile-build` is installed and the stale `huduglue-mobile-build` removed, so `/etc/sudoers.d/` is now free of pre-rename names. ## [3.17.515] - 2026-09-02 ### Fix: stale sudoers service names, unit drift, and the silent gap that caused both Two loose ends from the v3.17.492 rename, plus the reason neither was ever noticed. **Sudoers still named the pre-rename services.** `/etc/sudoers.d/clientst0r-auto-update` granted `systemctl restart huduglue-gunicorn.service` and four siblings — none of which exist any more. The rename updated `scripts/install_auto_update.sh`, but nothing re-runs that script on update, so the installed file kept the old names. Three further files (`huduglue-auto-update`, `huduglue-fail2ban`, `huduglue-install`) were left behind as exact duplicates of their `clientst0r-*` replacements, differing only in a comment header. The generator now also grants **`systemctl reload`**, which the zero-downtime path added in v3.17.512 actually depends on — without that rule an update silently falls back to a restart on any host that doesn't have blanket sudo. And `systemd-run` and `pkill`, previously granted bare, are pinned to the exact invocations the updater issues; unrestricted, either one is a root shell in all but name. **The unit file had drifted, and it mattered.** v3.17.500 raised the gunicorn worker timeout from 180s to 300s so AI documentation generation would stop being SIGKILLed mid-request and returning an HTML 500 instead of JSON (issue #138). That change landed in `deploy/clientst0r-gunicorn.service` — and stopped there. `update_instructions.sh` does a `git pull`; **nothing re-installs the unit**. The host had been running the old 180s for fourteen releases, so the fix for #138 shipped but never took effect, and nothing anywhere said so. Step 5 now diffs the installed unit against the one in the repo (comments and blank lines ignored) and warns when they differ, with the exact commands to review and adopt. It **warns only, never overwrites** — an operator may have tuned worker count, bind address or resource limits on purpose. The warning also notes that `ExecStart` changes need a restart rather than a reload, since a reload re-execs workers under the master's existing command line. Applied to this host: sudoers regenerated (validated with `visudo -c` before install), three stale files removed, unit brought to `--timeout 300`. That last one required a genuine restart — measured at 8 failed requests out of 55, ~2s — which is precisely the contrast with the reload path's 0/394, and the reason unit drift is worth catching at update time rather than discovering later. ## [3.17.514] - 2026-09-02 ### Fix: backslash escapes in seeded KB articles — including six that were silently corrupting content Following up the `SyntaxWarning`s noted in v3.17.513. Two things turned up that the warning count had hidden. **There were 170 bad escapes, not 15.** Python reports an invalid escape sequence once per source line, so the 15 warnings were 15 *lines*, not 15 occurrences. The real count across the two KB seeding commands was 170. **Six of them were already corrupting shipped article content, with no warning at all.** An invalid escape like `\P` is left as a literal `\P` and merely warns. But when a Windows path happens to put a *valid* escape letter after the backslash, Python converts it silently: | Article text intended | What actually shipped | |---|---| | `root\cimv2\terminalservices` | `root\cimv2` + **TAB** + `erminalservices` | | `C:\Data\file.txt` | `C:\Data` + **FORMFEED** + `ile.txt` | | `C:\Tasks\backup.xml` (×2) | `C:\Tasks` + **BACKSPACE** + `ackup.xml` | | `C:\Scripts\backup.ps1` (×2) | `C:\Scripts` + **BACKSPACE** + `ackup.ps1` | A technician copying that WMI namespace or path out of a seeded article got a broken command, and nothing anywhere reported a problem. These are the ones worth having found. **The fix and how it was verified.** Every backslash in a non-raw literal is doubled unless Python was already treating it as an intended escape. A survey first confirmed the only escapes to preserve were 17 deliberate `\\` and one line continuation — no `\x`, `\u`, `\U` or octal anywhere — so the transform is total and unambiguous. It then compared **every string constant's value** before and after via `ast`: 15 literals were rewritten and exactly **4 values changed**, those four being the article bodies holding the six corrupted paths. Everything else is byte-identical. `SyntaxWarning` count is now zero. ### Added - `core/tests/test_source_syntax.py::test_no_invalid_escape_sequences` — holds the codebase at zero `SyntaxWarning`s, so this class of bug (the silent variety included) can't creep back. ## [3.17.513] - 2026-09-02 ### Fix: close the last downtime window in updates — scoped bytecode purge + pre-compile v3.17.512 made updates use a graceful reload instead of a restart, but measuring one showed it wasn't quite zero: **2 requests out of 423** exceeded a 5-second budget at the moment of handover. The gunicorn log explained why — new workers boot, and old ones are SIGTERMed one second later: ``` 14:16:58 Handling signal: hup 14:16:58 Booting worker with pid: 930087 / 930089 / 930090 / 930092 14:16:59 Worker (pid:926458) was sent SIGTERM! ``` One second is not enough, because step 5 deleted **every** `.pyc` under `BASE_DIR` immediately beforehand — including the virtualenv's. Each fresh worker therefore had to recompile the whole of Django from source before it could serve anything, while the old workers were already retiring. Two problems with that purge, both fixed: - **It was unscoped.** `BASE_DIR` is a home directory, not just the app. On this host it also holds a 4.6 GB `android-sdk`, a 1.8 GB Expo checkout, and `snap`, `mobile-app` and `local_apps` dirs — so the walk covered gigabytes to delete a few hundred files. - **It wiped the venv.** `site-packages` bytecode is pip's to manage; destroying it bought nothing and caused the entire cold-start cost. Step 5 now discovers the project's own Python packages (top-level dirs holding an `__init__.py` — **0.015s**, no deep walk), purges only those, and pre-compiles them before signalling the reload, so new workers boot warm. Measured at **~2s**, and it runs while the old workers are still serving, so it costs update duration rather than downtime. The naive whole-tree alternative measured **162s**. Two things fell out of building it: - **The purge has been silently failing for a long time.** 22 `__pycache__` directories were root-owned (from a `manage.py` run under sudo), so `rm -rf` as the service user could never remove them — and `2>/dev/null || true` hid it every time. The step now counts what it could not delete and logs a `[WARN]` with the exact `chown` to fix it, instead of pretending it worked. - **`core/management/commands/seed_all.py` did not parse at all.** Line 82 had `'... they don\\'t need'` — an escaped backslash followed by a quote, which ends the string early. `manage.py seed_all` would have died with a `SyntaxError` on invocation. Django imports a management command only when it is run, so nothing had ever touched the module. The new pre-compile step parses every file, and found it in seconds. ### Added - `core/tests/test_source_syntax.py` — parses every Python file the project ships, so a file that cannot be imported can never reach a release again. Covers `SyntaxError` and `UnicodeDecodeError`, and guards its own discovery so it cannot pass vacuously. Known and deliberately left alone: 15 `SyntaxWarning: invalid escape sequence` across two KB seeding commands (Windows paths like `C:\gpreport.html` inside article bodies). Harmless today, a `SyntaxError` in some future Python. Fixing them means editing seeded article content, so it wants its own change with its own before/after comparison. ## [3.17.512] - 2026-09-02 ### Fix: updates are now zero-downtime — the gunicorn unit had no `ExecReload` Every update logged the same pair of lines: ``` [WARN] reload failed — falling back to restart (brief downtime expected) Step 5/5: Restart of 'clientst0r-gunicorn.service' scheduled via systemd-run (3-second delay) ``` `deploy/update_instructions.sh` has always *tried* a graceful reload first and only fallen back to a restart if that failed. It failed every time, for a mundane reason: ``` Failed to reload clientst0r-gunicorn.service: Job type reload is not applicable for unit clientst0r-gunicorn.service. ``` The unit had no `ExecReload=` line, so `systemctl reload` was never a valid operation on it and the fallback restart — with its few seconds of 502s — was the only path updates ever took. The unit now carries: ```ini ExecReload=/bin/kill -s HUP $MAINPID KillMode=mixed TimeoutStopSec=10 ``` SIGHUP makes the gunicorn master start fresh workers and retire the old ones one at a time while it holds the listening sockets open, so nothing is refused. `KillMode=mixed` sends the stop signal to the master alone, letting it wind its workers down gracefully rather than having systemd SIGTERM the whole cgroup at once. This is only a valid substitute for a restart because the app is **not** started with `--preload`: workers import the app when they fork, so new workers pick up new code from disk. Verified rather than assumed — with a sentinel version string written to disk, a reload, and the live `/health/` endpoint reporting the new value (then reverted and reloaded back). Measured on a live reload: **71 consecutive HTTPS requests, 0 failures**, master PID unchanged, all four workers replaced. Added in all three places the unit is defined — `deploy/clientst0r-gunicorn.service` and both inline heredocs in `install.sh` — so fresh installs get it too, not just this host. Note for anyone running backwards-incompatible migrations: a graceful reload briefly overlaps old and new workers, so old code runs against the new schema for a second or two. Additive migrations are unaffected; a destructive one still wants a hard restart. ## [3.17.511] - 2026-09-02 ### Fix: stray code-block language badges in every export (issue #144 follow-up) Testing v3.17.510's export against real Knowledge Base articles turned up a defect the unit tests couldn't see, because it only shows up against content the AI documentation generator produces. Those articles wrap every code block in Bootstrap chrome that pins a language label to the block's top-right corner: ```html
CODE
...
``` In the browser that badge floats *over* the corner of the code block. An export is linear, so it flattened into a stray one-word paragraph — a bare `CODE` or `POWERSHELL` line hanging above every single code block. One Global KB article carried **29** of them, and they cost it a whole extra page of PDF. The block parser now drops elements that are chrome rather than content, wholesale including their subtree: `position-absolute` (an overlay is by definition not in the document flow), plus `d-print-none`, `no-print`, `visually-hidden`, `visually-hidden-focusable`, `sr-only` and `sr-only-focusable` — classes that state outright the element isn't meant for a printed page, which is exactly what an export is. Care was taken with the drop scope: it tracks nesting depth so it closes at its *own* end tag rather than swallowing everything after it, and a void tag (``) never opens a scope at all, since it has no end tag to close one. Measured on the real article: paragraph blocks 67 → 38 (exactly the 29 badges), all 29 code blocks preserved, PDF 10 pages → 9. ## [3.17.510] - 2026-09-01 ### Feature: document export — Markdown, print-ready HTML, DOCX and PDF (issue #144) The Knowledge Base could take documentation *in* (v3.17.503 added DOCX/PDF/TXT/MD import) but had no way to get it back *out*. Issue #144 put it plainly: *"client departing needs documentation and you should treat outbound as good as inbound."* This is the matching outbound half. **Per-article export.** Every KB article and Global KB article gains an **Export** menu: - **PDF** — letter-size, ReportLab-rendered, with a title block, running page footer, real tables, bullet/numbered lists, code blocks and clickable links. - **Word (.docx)** — a genuine OOXML package built straight into the ZIP with `zipfile`, mirroring how the import side reads DOCX without python-docx. Ships `styles.xml` (Title / Subtitle / Heading 1-6 / Quote / Source Code) and `numbering.xml`, so headings show up in Word's navigation pane, lists are real Word lists, tables are real Word tables and hyperlinks are live external relationships — not a text dump with a `.docx` extension. - **Markdown (.md)** — an article authored *as* Markdown exports its own source verbatim, so the import → edit → export round-trip is lossless. HTML articles are converted from their rendered body. - **HTML (print-ready)** — one standalone, self-contained page with the stylesheet inlined. **Bulk export.** **Export All** on the document list bundles every document the current category / tag / search filters select into a single ZIP, in whichever format you pick, alongside an `index.md` table of contents grouped by category. That is the departing-client handover in one click, and the archive is readable with no app at all. **Print visibility.** The issue asked for output that prints legibly — *"easy to print visibility ie obscure background images etc"*. Both the exported HTML and the article page itself carry an `@media print` block that drops background images, box shadows and text shadows, forces black-on-white body text, hides the app chrome (nav, sidebar, action buttons), expands link targets to their URLs, and keeps tables, code blocks and images off page breaks. **Implementation.** `docs/services/document_export.py` parses a document's rendered, already sanitised HTML **once** into a neutral block structure (headings / paragraphs / list items / quotes / code / tables / rules / images, each with formatted inline runs). All four writers consume that same structure, so the formats can't drift apart — fix the parser, fix everything. No new dependencies: ReportLab was already required for the PSA quote/invoice PDFs, and DOCX is hand-rolled. New routes (all login-required, all org-scoped exactly like the matching detail view, all served `Cache-Control: private, no-store` since exports carry client-confidential documentation): - `GET /docs//export//` — one article (`?inline=1` on `html` opens the print preview in the browser instead of downloading). - `GET /docs/kb//export//` — one Global KB article (staff only). - `GET /docs/export//` — ZIP archive, honouring `?category=`/`?tag=`/`?q=`. A file-upload document (an opaque PDF/DOCX/image blob) redirects to the original file rather than exporting an empty shell. ## [3.17.509] - 2026-08-01 ### Fix: migration drift in `files`, `imports`, and `reports` `manage.py migrate` had been reporting *"Your models in app(s): 'files', 'imports', 'reports' have changes that are not yet reflected in a migration"* on every update run. Five field edits had been made in the models without a matching migration, so a fresh install's recorded migration state no longer matched the models. Nothing was broken at runtime — every one of the five is a Python-level attribute — but the drift meant `makemigrations --check` could never be clean and the warning masked any *real* drift that appeared later. - `files.Attachment.entity_type` — `choices` gained the vehicles entries (`vehicle`, `damage_report`, `fuel_log`, `vehicle_receipt`). - `imports.ImportJob.source_type` — `choices` gained `csv`; `skip_duplicates` and `source_file` had reworded `help_text`; `source_file.upload_to` moved from `imports/magicplan/%Y/%m/` to `imports/files/%Y/%m/` when the importer grew CSV support. - `reports.SavedQuery.target_model` — `choices` + `help_text`. All three catch-up migrations are **no-ops against the database** — `sqlmigrate` emits `-- (no-op)` for each, and the columns are byte-for-byte identical before and after. The `imports` one needed care: `upload_to` is not in Django's `Field.non_db_attrs`, so the autodetector classifies it as schema-affecting. On SQLite that turns into a full `import_jobs` table rebuild — copy every row to a new table, drop the original, rename, recreate seven indexes — for a change that alters no column. That migration therefore wraps its operations in `SeparateDatabaseAndState` with an empty `database_operations`, so Django records the new field state and touches no data. Verified against a copy of a production database: the `import_jobs` schema (table + all seven indexes) is identical after applying. `makemigrations --check` now reports "No changes detected" across the whole project. ## [3.17.508] - 2026-08-01 ### Feature: send a ticket reply to the requester from the ticket form (issue #142) The Graph/SMTP outbound transport added in 3.17.507 had no caller in the app — replies were saved as comments and never actually left the server, so nothing reached the customer and nothing was written to the mailbox's Sent Items. This release wires the reply box to that transport. - **Opt-in checkbox** — the ticket reply form gains "Email this reply to ``", shown only when the ticket has a requester address. **Off by default**, so no existing workflow starts emailing customers after upgrading; agents tick it per reply. - **Internal notes are never emailed** — enforced server-side (the flag is ignored on an internal note) and reflected in the UI, which disables and clears the checkbox when "Internal note" is ticked. - **Transport follows the mailbox** — `email_outbound.resolve_ticket_email_config()` picks the config that ingested the ticket's most recent inbound message, so a reply leaves from the mailbox it arrived on and preserves the M365 conversation. It falls back to the organization's single active config, and deliberately returns `None` (plain SMTP backend) rather than guessing when two or more are candidates. - **Failures never lose work** — the comment is committed before the send is attempted. Missing requester address, an unavailable Graph connection, or a transport exception all report through the message banner while the comment stays saved. Graph outcomes are reported distinctly: accepted, queued-for-retry, failed, and `uncertain` (submitted but unconfirmed — flagged for manual check rather than silently resent). - **Audited** — each send writes an `AuditLog` entry recording transport, resulting status, and recipient. - Tests: `psa/tests/test_reply_email.py` — opt-in gate, SMTP send, Graph send, internal-note suppression, missing-requester handling, failure-keeps-comment, and the four config-resolution cases. ## [3.17.507] - 2026-07-26 ### Feature: Microsoft 365 outbound email via Graph (optional transport, issue #142) Staff replies can now be sent through Microsoft Graph (`sendMail` / `reply` / `replyAll`) as an **optional** outbound transport on M365 mailbox connections. **SMTP outbound is unchanged and remains the default** — existing IMAP/SMTP customers are unaffected, and existing records default to SMTP (Graph outbound is never auto-enabled). - **Transport selector** — `EmailIngestionConfig.outbound_method` (`smtp` | `graph`). Graph is only usable when the mailbox has a valid, active M365 connection + mailbox address. The sender is always the server-configured mailbox — callers can't supply an arbitrary sender. - **Graph operations** (`integrations/providers/m365.py`) — `send_mail` (new messages, `saveToSentItems` on), `reply_message` / `reply_all_message` (used when the original message was ingested via Graph, preserving the M365 conversation). All send `Prefer: IdType="ImmutableId"`. - **Immutable message ids** — the inbound Graph poller now requests ImmutableIds and stores them on `EmailMessage.graph_message_id`, so a later reply targets the right message even after it moves folders. - **Durable, idempotent delivery** — new `EmailOutboundJob` model + `psa_send_outbound` worker. Graph returns HTTP 202 (accepted, not delivered); jobs track queued/sent/failed/uncertain, accepted-at, retry count, Microsoft request id, and a last-error summary. An atomic claim-lock prevents duplicate sends; transient errors (429/5xx/connect-timeout) retry with backoff honoring `Retry-After`; auth/validation/malformed-recipient errors fail fast; a read-timeout after submission is marked **uncertain** and never auto-resent. Message bodies are never logged. - **Content** — HTML (sanitized with the app's bleach allowlist) + text, To/Cc/Bcc, Reply-To, and attachments (from `TicketAttachment`, size/MIME validated) — at parity across SMTP and Graph. - **Setup UX** — the mailbox form has an outbound-method selector, a readiness check with separate inbound (Mail.Read) and outbound (Mail.Send) status, and a **Test outbound** button that sends a clearly-labeled test to an admin-chosen recipient. No secrets/tokens are exposed. - **Fallback** — optional pre-submission SMTP fallback when Graph is known-unavailable; never after a Graph submission (avoids duplicates). Each message records which transport sent it. - **Docs** — M365 setup page now documents `Mail.Read` (inbound) and `Mail.Send` (outbound) as optional, and Exchange Online RBAC for Applications as the preferred way to scope them to only the PSA mailbox, warning that an unrestricted Entra grant defeats the RBAC scope. - Migration `psa/0058`. Tests: `psa/tests/test_graph_outbound.py` (24 tests across the 20 required scenarios) plus the existing IMAP/SMTP suites still green. ## [3.17.506] - 2026-07-26 ### Feature: Microsoft 365 mailbox sync for PSA inbound via Graph API (issue #142) PSA inbound email can now poll a Microsoft 365 mailbox over the **Graph API** instead of IMAP — for tenants that can't (or won't) enable IMAP on M365. The Graph path reuses an existing M365 connection's app registration, so no extra credentials are stored. - `psa/models.py`: `EmailIngestionConfig` gains a `source` discriminator (`imap` | `graph`), an optional `m365_connection` FK, and a `graph_mailbox` field. IMAP fields are now blank-able. Migration `psa/0057`. - `integrations/providers/m365.py`: `M365Provider` gains mailbox methods — `list_unread_message_ids`, `get_message_mime` (fetches full RFC822 MIME so the existing stdlib-email parser is reused unchanged), `mark_message_read`, and `probe_mailbox` (for a config access check). Requires the `Mail.Read` application permission (documented in the M365 setup form). - `psa/management/commands/psa_poll_email.py`: refactored so the per-message processing (threading, quarantine, routing, attachments, `EmailMessage` persistence) is a single source-agnostic `_process_message()`. `_poll_imap` and the new `_poll_graph` both feed it. The existing `*/5` cron entry needs no change — the same command now handles both sources. - `psa/views.py` + `templates/psa/email_config_form.html`: the mailbox form has a Source selector that toggles between IMAP fields and a Microsoft 365 connection + mailbox address; the list view shows an M365 badge for Graph configs. - Tests: `GraphMailboxPollTests` (ticket creation, reply threading, marks-read, missing-connection error) and `M365ProviderMailMethodTests` in `psa/tests/test_phase10_email.py`. ## [3.17.505] - 2026-07-26 ### Fix: M365 sync showed a false "missing SecurityAlert.Read.All permission" warning (issue #143) When syncing a tenant that isn't onboarded/licensed for Microsoft Defender, Graph's `/security/alerts_v2` endpoint returns **HTTP 403** even though the app's `SecurityAlert.Read.All` permission is correctly granted and consented. The Defender-alerts handler treated *every* 403 as a missing permission and told the admin to add a permission they already had. - `integrations/providers/m365.py`: `get_defender_alerts()` now parses the Graph 403 error body and only reports `permission_error` when the error is genuinely an authorization/consent denial (`Authorization_RequestDenied` / "Insufficient privileges" / consent / token-validation). Any other 403 (tenant not onboarded/licensed for Defender) returns an `unavailable` marker with the tenant's reason instead. - `integrations/views.py`: the Defender section renders a neutral "Defender alerts are unavailable for this tenant — this does not affect the rest of the M365 sync" note for the `unavailable` case; the genuine-permission warning now adds a hint to re-run the sync if consent was just granted (a token issued before consent won't carry the new permission). - Tests in `integrations/tests.py` (`M365DefenderAlert403Tests`) cover permission-denial vs. not-onboarded vs. non-403 vs. success. ## [3.17.504] - 2026-07-22 ### Fix: PSA settings save crashed with "Object of type Decimal is not JSON serializable" (issue #141) Saving **Settings → PSA/AI** (`/psa/settings/`) 500'd on every POST. The audit-log change diff compared a stringified snapshot of `psa_ai_min_confidence` against the live **Decimal** value, so the field (a) always showed as "changed" even when it wasn't, and (b) put a raw `Decimal` into `AuditLog.extra_data` — a `JSONField` using the plain `json.JSONEncoder`, which cannot serialize `Decimal`, crashing the save. - `psa/views.py`: build the "current" snapshot with the **same** normalisation as the "previous" one (stringify the Decimal) before diffing, so the comparison is like-for-like and nothing non-serialisable reaches the JSONField. - Tests: `psa/tests/test_global_settings_view.py` — save no longer 500s, `extra_data` round-trips through JSON, and an unchanged confidence is no longer falsely reported as changed. ### Fix: AI-reviewed imports rendered as raw Markdown instead of HTML (issue #140) The import AI review always stored the result as `content_type='html'`. But models frequently return **Markdown** despite the HTML prompt (especially for SOP-style source docs), so the reader saw literal `##` / `**` markup — "goes to MD format instead of html" — with no error, because the call had *succeeded*. - `docs/views.py`: `ai_review_import` now detects whether the returned content is actually HTML (`_looks_like_html`) and stores the matching `content_type`, so `render_content` converts Markdown on display instead of showing raw markup. The response now returns `content_type`. - `templates/docs/document_import.html`: a reverse proxy timing out a slow review returns a non-JSON 502/504 page; the client used to surface a bare "Review failed". It now detects the non-JSON response and explains it was a server/proxy timeout (likely an oversized document). - Tests: `docs/tests.py` — Markdown output is stored as `content_type='markdown'`, plus `_looks_like_html` unit coverage. ## [3.17.503] - 2026-07-21 ### Feature: import DOCX/PDF as editable docs with optional AI review (issue #140) A new **Import & AI Review** flow at `/docs/import/` turns uploaded Word, PDF, text, and Markdown files into editable Knowledge Base documents — extracting their text into `Document.body` (searchable/editable) rather than storing them as opaque file blobs like the existing "Upload Files" path. Bulk by design: select many files at once. Optionally, an **OPTIONAL AI** review runs after import (gated by `psa_ai_enabled` **and** a configured LLM provider). For each imported document the AI reformats it to a chosen documentation standard (general / SOP / runbook / network / security / M365 / AD) and returns a list of gaps for improvement, surfaced on the results page. - `docs/services/document_import.py` (new): `extract_document_text()` — DOCX via stdlib `zipfile` + XML (no new dependency), PDF via **PyMuPDF** (`fitz`), plus TXT/Markdown. Guards against corrupt files, image-only PDFs, legacy `.doc`, and oversized files (60k-char cap, flagged as truncated). - `docs/services/ai_documentation_generator.py`: new `review_imported_document()` — one LLM call that returns the reformatted body **and** a parsed gap list (split on a sentinel so HTML-in-JSON fragility is avoided). - `docs/views.py`: `document_import` (bulk extract + create) and `ai_review_import` (per-document AI review, one short request each so the gunicorn worker timeout is never at risk — cf. #138). New `_docs_ai_ready()` helper enforces the `psa_ai_enabled` gate. - New template `templates/docs/document_import.html`; **Import & AI Review** button on the documents list; routes `docs:document_import` and `docs:ai_review_import`. - `requirements.txt`: added `PyMuPDF>=1.24,<2`. - Tests in `docs/tests.py`: extraction (DOCX/PDF/TXT, corrupt/unsupported/truncated), review parsing (marker split, code-fence strip, provider failure), and the import + AI-review views (bulk create, AI-off gating, reformat-and-save). ## [3.17.502] - 2026-07-17 ### Fix: creating/editing a file document crashed with AttributeError (issue #139) Uploading a file at `/docs/create/` (or replacing one at `/docs//edit/`) raised `'FieldFile' object has no attribute 'content_type'`. The MIME type was read off `document.file` — a model `FieldFile`, which has no `content_type` — instead of the in-request `UploadedFile`. - `docs/views.py`: `document_create` / `document_edit` now resolve `file_type` via a new `_uploaded_file_type()` helper that reads `content_type` from `request.FILES`, with a `mimetypes.guess_type()` fallback from the filename. This mirrors the bulk-upload path, which was already correct. - Regression tests added in `docs/tests.py` (`DocumentFileUploadTests`) covering create and edit-replace uploads. ## [3.17.501] - 2026-07-14 ### Security: patch 3 Dependabot advisories (cryptography, bleach) Bumped two dependencies in `requirements.txt` to clear all open Dependabot alerts: - **`cryptography` → `>=48.0.1,<49.0.0`** (was `46.0.7`) — *high*: the wheels bundled a vulnerable OpenSSL build. Used by the vault/secrets encryption (Fernet, AES-GCM, PBKDF2). - **`bleach` → `>=6.4.0,<7`** (was `6.1.*`) — *medium*: `clean()`/`Cleaner()` failed to sanitize dangerous URI schemes in allowed `formaction` attributes; *low*: URI-scheme sanitization could be bypassed with Unicode above U+00A0. Used to sanitize KB/AI-generated HTML (`docs.models`) and inbound PSA email (`psa.email_parsing`). Verified in an isolated environment that the new versions resolve against the full requirements set with no conflicts and are API-compatible with existing usage: the issue-#127 progress-bar `width: N%` styling still survives `CSSSanitizer`, non-allowlisted CSS properties are still dropped, `javascript:` hrefs are stripped, and the cryptography primitives (Fernet / AES-GCM / PBKDF2) round-trip correctly. No application code changes were required. ## [3.17.500] - 2026-07-14 ### Fix: AI document generation failed with "Server returned HTML instead of JSON" (issue #138) Generating a document with the AI assistant failed with `Internal Server Error (500) - Server returned HTML instead of JSON` — reported with both the Ollama and Anthropic/Claude providers. Despite the user's read ("it's expecting JSON when I selected rich HTML"), the cause was **not** response parsing: the `ai_generate` view and the generator service already wrap everything and always return `JsonResponse`. The frontend only shows that message when a response is non-2xx **and** its body isn't JSON — i.e. a web-server HTML error page. **Root cause — timeout budget inversion.** AI generation legitimately runs for minutes (local Ollama models on CPU especially), but the per-request HTTP timeouts were configured *above* the gunicorn worker `--timeout` (120s in Docker, 180s on the systemd unit). So gunicorn SIGKILLed the worker mid-generation and the browser received gunicorn's HTML 500 page — before the provider's own timeout could return a clean JSON error. The Ollama provider's 300s timeout could never fire behind a 120s worker. **Fix — align the timeout budget across every layer:** - Gunicorn worker `--timeout` raised to **300s** in both `Dockerfile` and `deploy/clientst0r-gunicorn.service`. - Nginx `proxy_read_timeout` / `proxy_send_timeout` raised to **300s** in the Docker and native reverse-proxy configs (only relevant when the proxy profile is enabled). - Provider HTTP timeouts capped at **280s** (`AI_HTTP_TIMEOUT`), strictly *below* the worker timeout, so a genuinely stuck/slow model now raises a caught `Timeout` and returns a clean JSON error ("Ollama did not respond within 280s…") instead of killing the worker. Applied to the Ollama request and, via an explicit client `timeout`, the Anthropic and MiniMax-Coding providers (the SDK default was ~600s). - Added `docs.tests.AIProviderTimeoutTests` locking the invariant (provider timeout < worker timeout) and asserting a provider `Timeout` degrades to a JSON error rather than an exception. ## [3.17.499] - 2026-06-29 ### Fix: AI features reported a "missing API key" even when one was saved (issue #137) "Import from Property Appraiser URL using AI" (and any other AI feature) failed with a missing-key error despite a key being configured in **Settings → AI & LLM**. The settings page writes the key to `.env` and reloads gunicorn, but `config/settings.py` called `load_dotenv()` without `override=True`. Under Docker Compose the `env_file: .env` directive injects the (initially empty) `ANTHROPIC_API_KEY` into the container environment at startup, and `load_dotenv()` will **not** overwrite a variable that already exists in the environment — so the key saved through the UI never took effect. - `config/settings.py` now calls `load_dotenv(override=True)`, making the `.env` file authoritative over stale process-environment variables. After the automatic reload, a key saved via the UI is picked up. This fixes every AI feature, not just property import. - Safe because `.env`/`.env.*` are dockerignored, so the image never bakes in DB credentials; `override` only ever affects the AI/maps keys the settings UI manages. ## [3.17.498] - 2026-06-29 ### Fix: creating a rack wiring connection failed with "This field is required" (issue #136) Adding a cable/wiring connection between two devices in a rack failed even with every field filled in, returning `{"from_device":["This field is required."], ...}` for all fields. The rack detail page submits the connection via `fetch()` with a JSON body (`Content-Type: application/json`), but `rack_connection_create` / `rack_connection_edit` only read `request.POST` — which is empty for a JSON request — so every required field looked missing. - The two views now read the payload through a small `_rack_connection_post_data()` helper that parses a JSON body when the request is `application/json` and otherwise falls back to `request.POST`. Classic (non-JS) form posts keep working unchanged. - Added `monitoring.tests.RackConnectionCreateViewTests` covering both the JSON-body path (the failing case) and the form-encoded path. ## [3.17.497] - 2026-06-24 ### Fix: TOTP secret entry was hidden on every password type except "OTP" Storing a 2FA/TOTP secret alongside *any* credential (website, email, database, SSH, API key, etc.) has been supported in the backend since the feature shipped, but the **password entry form only rendered the TOTP fields when the type was set to "OTP/TOTP (2FA)"** — so on every other type the "TOTP Secret" / "Issuer" / "Auto-generate" controls were invisible and a user could never attach a code. Reported in discussion #21. - The TOTP fields are now an **always-visible, optional "Two-factor (TOTP)" card** on the add/edit form, available regardless of the selected password type. - The form's save path, the live-code generator on the detail view (`{% if password.otp_secret %}`), and the `generate_otp` API were already type-agnostic — no backend change was required. - Added `vault.tests.PasswordFormTOTPForAnyTypeTests` covering both an explicit Base32 secret and the auto-generate path on non-OTP types (website / database), asserting the password **and** the TOTP secret persist on one record. ## [3.17.496] - 2026-06-19 ### Feature: multi-organization REST API access (issue #134) The public REST API can now address **multiple client organizations through a single API key** — built for MSPs running a single-pane-of-glass integration that needs to read/write across every client without one key per client. Fully opt-in and backward-compatible: existing keys, and any new key left on the default scope, behave exactly as before. **Per-key organization scope** — new `APIKey.scope` field with three values: - `single` *(default)* — the key's home organization only (legacy behavior). - `descendants` — the home organization plus every sub-location beneath it (Phase 18 hierarchy). - `all` — every organization the key's owner can access (all active orgs for an MSP staff user / superuser, or the owner's active memberships). A key never grants more access than its owner already has. **Per-request selection** — list/detail/create/update endpoints accept an optional `organization` query parameter: - omitted → home/current org for `single` keys & web sessions; all accessible orgs for `descendants` / `all` keys. - `?organization=` → narrows to one org (403 if inaccessible). - `?organization=all` → every accessible org. **Organization surfaced on every resource** — `organization` + `organization_name` now appear on assets, contacts, documents, and passwords so a consumer can group rows by client; `organization` is writable on create to target a specific client. `GET /api/organizations/` returns the full accessible client set for discovery. **Security** — cross-org isolation enforced on every request; password reveal / OTP audit rows are logged against the row's own organization for accurate multi-client traceability; scope is bounded by the owner's live permissions at request time. New scope selector in **Settings → API Keys → Create API Key**. Contract documented in `docs/api-multi-org.md`. New migration `api/0003_apikey_scope`. 11 new tests in `api/tests.py` covering single-scope isolation, all-scope fan-out, descendants, the `?organization=` param, cross-org create rejection, and the discovery endpoint. ## [3.17.495] - 2026-05-25 ### Fix: auto-update + breach-scan timers were firing twice daily Both `clientst0r-auto-update.timer` and `clientst0r-breach-scan.timer` had two `OnCalendar=` directives — systemd treats those as additive, so each timer was triggering twice a day instead of once. **`clientst0r-auto-update.timer`** had: ``` OnCalendar=daily # = midnight every day OnCalendar=02:00 # 02:00 every day ``` Result: fired at 00:00 AND 02:00. Two auto-update attempts per day. Removed the redundant `daily` line; now fires once at 02:00 local. `OnBootSec=10min` + `Persistent=true` keep the catch-up behavior for hosts that were off at 02:00. **`clientst0r-breach-scan.timer`** had: ``` OnCalendar=daily OnCalendar=*-*-* 02:00:00 ``` Result: same problem — fired at 00:00 AND 02:00. Removed the redundant `daily` line; now fires once at 02:00 local with `RandomizedDelaySec=1800` (up to 30 min jitter) preserved to avoid HIBP API peak times. No other timer affected — `monitor`, `psa-sync`, `rmm-sync`, `scheduler` all use single `OnBootSec` + `OnUnitActiveSec` constructs, no duplicated calendars. After Apply + re-running `deploy/migrate-from-huduglue.sh` (or just `cp deploy/clientst0r-{auto-update,breach-scan}.timer /etc/systemd/system/ && sudo systemctl daemon-reload`), each timer fires once per day at 02:00 instead of twice. ## [3.17.494] - 2026-05-25 ### itdocs-scheduler renamed to clientst0r-scheduler; itdocs-gunicorn removed Follow-up to the v3.17.492 brand scrub. Two legacy `itdocs-*` systemd units were left in place last time because the names themselves weren't huduglue-branded. With the rest of the stack consistently on `clientst0r-*`, those holdouts looked even more out of place — and `itdocs-gunicorn.service` was an installed-but-disabled duplicate of `clientst0r-gunicorn.service`. This release retires both. **Renamed — task scheduler.** `deploy/itdocs-scheduler.{service,timer}` → `deploy/clientst0r-scheduler.{service,timer}`. The new service runs the same `manage.py run_scheduler` every minute, just under a clientst0r-branded unit name with `SyslogIdentifier=clientst0r-scheduler`. The timer's `Requires=` reference also points at the new service name. **Deleted — obsolete deploy templates.** `deploy/itdocs-gunicorn.service` (replaced by `clientst0r-gunicorn.service` in v3.17.492), `deploy/itdocs-scheduler.{service,timer}` (renamed), `deploy/itdocs-monitor.{service,timer}` + `deploy/itdocs-psa-sync.{service,timer}` (duplicates of `clientst0r-monitor.*` / `clientst0r-psa-sync.*` — both timers were firing the same management commands on a different cadence). **Nginx config rename.** `deploy/nginx-itdocs.conf` → `deploy/nginx-clientst0r.conf`. Upstream block, log paths, static-files alias, and SSL cert paths all rebranded. The internal `/var/lib/itdocs/uploads/` mount path is preserved (changing it would require migrating live upload data). **Code references.** Removed `itdocs-gunicorn.service` from systemd unit-name lookup lists in `core/views.py`, `core/updater.py`, `core/management/commands/auto_heal_version.py`, plus the bash equivalents in `update.sh`, `scripts/auto_update.sh`, and `deploy/update_instructions.sh`. The only service name considered now is `clientst0r-gunicorn.service`. `templates/core/settings_scheduler.html` (operator-facing systemd-help block) updated to reference `clientst0r-scheduler` in all `systemctl` / `journalctl` examples. **bootstrap_ubuntu.sh.** Fresh-install instructions now point at the clientst0r-* unit files, add the scheduler timer to the install sequence, and the upgrade path restarts `clientst0r-gunicorn` (was `itdocs-gunicorn`). **Extended migration script — `deploy/migrate-from-huduglue.sh`.** Same one-shot script as v3.17.492, now also installs `clientst0r-scheduler.{service,timer}`, and stops/disables/removes the legacy `itdocs-gunicorn.service`, `itdocs-scheduler.{service,timer}`, `itdocs-monitor.{service,timer}`, `itdocs-psa-sync.{service,timer}`. Also calls `systemctl reset-failed` at the end to clear any cached failure records. Idempotent — safe to re-run. No code changes affecting request handling, no migrations, no mobile rebuild. Canonical install runs `deploy/migrate-from-huduglue.sh` after Apply to flip the live systemd units. ## [3.17.493] - 2026-05-25 ### Fix: breach-scan systemd unit was passing a non-existent CLI flag `clientst0r-breach-scan.service` (and its huduglue-era predecessor before v3.17.492) called: ``` manage.py check_password_breaches --scan-frequency 24 ``` But `--scan-frequency` was never a valid argument on that management command. Its real arg surface is `--force / --password-id / --organization-id` only. systemd exit-coded with `status=2/INVALIDARGUMENT` on every run since at least 2026-02-20, with the actual Python error landing in `/var/log/itdocs/breach-scan-error.log` (where it wasn't being noticed because the timer kept "firing" without anyone caring). **Fix — `deploy/clientst0r-breach-scan.service`:** - ExecStart now invokes the command with no extra flags: `manage.py check_password_breaches`. - The scan-frequency knob already lives ON each password as `custom_fields.hibp_scan_frequency` (default 24h, used inside `_filter_by_scan_frequency()`). The CLI flag was redundant even if it had existed. After Apply, the timer-fired runs exit 0 and log "Checking N passwords..." instead of an argparse error. ## [3.17.492] - 2026-05-25 ### Full HuduGlue → Client St0r rename (no leftover brand traces) Project-wide scrub of the legacy `huduglue` / `HuduGlue` brand name. The hostname `huduglue.agit8or.net` is kept (live DNS for the canonical dev/demo server, not a brand surface). Historical CHANGELOG entries are preserved as-is — they're a record of what happened, not what's true now. **Code rename — service-name detection.** `core/views.py`, `core/updater.py`, `core/security_views.py`, `core/management/commands/auto_heal_version.py`, `core/debug_version_view.py`, `templates/core/system_updates.html`, `templates/core/python_scanner_dashboard.html`. The systemd-unit-name lookup lists now contain `clientst0r-gunicorn.service` (+ `itdocs-gunicorn.service` as legacy fallback only). Every visible reference to `huduglue-gunicorn.service` in user-facing UI / error messages / debug output is now `clientst0r-gunicorn.service`. **Shell scripts.** `update.sh`, `update_safe.sh`, `scripts/auto_update.sh`, `scripts/hsts_bump.sh`, `deploy/update_instructions.sh`, `deploy/setup_mobile_build.sh`, `setup_gui_updates.sh` — all `huduglue` references renamed to `clientst0r`. The sudoers template at `setup_gui_updates.sh` now grants passwordless sudo on the canonical service names only. **Deleted dead legacy scripts.** `COMPLETE_REMOTE_FIX.sh`, `DIAGNOSE_REMOTE.sh`, `ENABLE_GUI_UPDATES.sh`, `FINAL_REMOTE_FIX.sh`, `FIX_REMOTE_UPDATES.sh`, `START_GUNICORN_REMOTE.sh`. All of these assumed the project lived at `/home/administrator/huduglue/` — a path that hasn't existed in this codebase. They were broken on every install. **Documentation.** `docs/PUBLIC_RELEASE_CHECKLIST.md` — three "play-reviewer test account on huduglue" references replaced with "on the canonical server"; the `/play_publish/` UI is now described as living on "the canonical server" instead of "huduglue". `docs/PLAY_STORE_LISTING.md` and `docs/PLAY_DATA_SAFETY.md` keep the literal `huduglue.agit8or.net` URL because those are Play Console submission docs and the URL is the live demo server. **Deploy unit files — systemd renames.** Eleven unit files renamed: `huduglue-{auto-update, breach-scan, monitor, psa-sync, rmm-sync}.{service,timer}` → `clientst0r-{...}.{service,timer}`. New `deploy/clientst0r-gunicorn.service` matches the live unit on the canonical install (Type=notify, gunicorn config). Three sudoers files renamed: `huduglue-{fail2ban, install, mobile-build}-sudoers` → `clientst0r-{...}-sudoers`. Two fail2ban configs (`huduglue-fail2ban-{filter,jail}.conf`) renamed to `clientst0r-fail2ban-{...}.conf` with jail / iptables-chain name `clientst0r`. **Stale itdocs unit Descriptions.** `deploy/itdocs-{gunicorn,monitor,psa-sync,scheduler}.{service,timer}` all had `Description=HuduGlue ...` lines; bulk-replaced with `Description=Client St0r ...`. The unit names themselves stay `itdocs-*` because that's a separate legacy install lane, not the huduglue brand. **Docker MariaDB config.** `docker/mariadb/conf.d/huduglue.cnf` renamed to `clientst0r.cnf`. MariaDB picks up any `*.cnf` in the conf.d directory — filename change is purely cosmetic, no behavior change. **Live unit migration helper — `deploy/migrate-from-huduglue.sh`.** One-shot bash script (must be run as root) that handles a running install with the legacy `huduglue-*` systemd units. It: (1) installs all new `clientst0r-*` unit files into `/etc/systemd/system`, (2) stops + disables + removes the corresponding `huduglue-*` units (plus the `huduglue-gunicorn.service.backup` lingering on prod), (3) enables + starts the new units, (4) reloads systemd, (5) prints the new active state. Idempotent. The script ITSELF references "huduglue" by design — that's how it knows what to migrate from. **Deleted stale artifact.** `security_scan_bandit.json` — captured huduglue-era code excerpts in scan output. Removed; regenerated next scan. No migrations. No mobile rebuild. After Apply the canonical install runs `deploy/migrate-from-huduglue.sh` to flip the live systemd units. ## [3.17.491] - 2026-05-23 ### Surface Docker install as a headline feature Follow-up to v3.17.490. The Docker install path landed in v3.17.490 but only showed up on GitHub as a buried block inside Quick Start. This release promotes it. - **README.md** — "What's New" section header bumped from v3.17.444 to v3.17.490; new top bullet calls out Phase 42 (Docker / docker-compose) with the one-line `git clone && docker compose up -d` pitch, a summary of what ships in the box (Dockerfile + compose profiles + GHA publishing + Makefile + .env.example + docs/docker.md), and an explicit "classic `bash install.sh` path is unchanged" note. - **FEATURES.md** — "Performance & Deployment" section now lists both install paths side-by-side and adds a dedicated "Docker / containerized deployment" sub-section enumerating Dockerfile / compose / dev override / entrypoint / GHCR workflow / Makefile / docs. No code changes; documentation only. ## [3.17.490] - 2026-05-23 ### First-class Docker / docker-compose deployment (Phase 42) `git clone && docker compose up -d` now stands up a working Client St0r install in under a minute. The classic `bash install.sh` path is unchanged — Docker is a peer install option, not a replacement. **Image (`Dockerfile`).** Multi-stage build on `python:3.12-slim`. Stage 1 compiles wheels with `build-essential` + `libmariadb-dev`; Stage 2 copies the venv into a slim runtime image with only `libmariadb3` + `curl`. Runs as non-root `clientst0r` (uid 1000). `collectstatic` runs at container start (not build) so it picks up the operator's env. `HEALTHCHECK` hits a new dedicated endpoint. **Health endpoint (`core/views.py` + `config/urls.py`).** New `GET /health/` returns `{"ok": true, "version": "3.17.490"}` with HTTP 200. No auth, no DB hit on the read path — safe to expose to load balancers and Docker healthchecks. **Compose (`docker-compose.yml`).** Two services start by default: `app` (Django + gunicorn) and `db` (MariaDB 10.11). Optional services gated behind profiles: - `--profile proxy` — adds an Nginx reverse proxy (port 80/443, with TLS-ready config in `docker/nginx/conf.d/clientst0r.conf`). - `--profile cache` — adds Redis. Only useful if the operator switches Django's `CACHES` away from the default in-process `locmem`. Required env vars (`SECRET_KEY`, `DB_ROOT_PASSWORD`, `DB_PASSWORD`) are enforced with compose's `${VAR:?message}` syntax so `docker compose up` fails loudly rather than booting into a half-configured state. Container names, volume names, and the network are all prefixed `clientst0r-*`. The old `huduglue.conf` nginx config was renamed and rebranded. **Dev override (`docker-compose.dev.yml`).** Use with `make dev-up` or `docker compose -f docker-compose.yml -f docker-compose.dev.yml up`. Source tree bind-mounted; `gunicorn --reload` watches for changes; defaults to `DB_ENGINE=sqlite3` so the dev stack doesn't need MariaDB; `DEBUG=True` + `ALLOWED_HOSTS=*` + all `SECURE_*` flags off. **Entrypoint (`docker-entrypoint.sh`).** Skips the DB-readiness wait when `DB_ENGINE=sqlite3`. Runs `migrate --noinput` then `collectstatic --noinput`. If `DJANGO_SUPERUSER_USERNAME` / `DJANGO_SUPERUSER_EMAIL` / `DJANGO_SUPERUSER_PASSWORD` are all set, bootstraps a superuser on first boot (idempotent — won't re-create on subsequent boots). **Image publishing (`.github/workflows/docker-image.yml`).** Buildx workflow that builds on every PR (validation only) and publishes to `ghcr.io/agit8or1/clientst0r` on push to `main` or any `v*` tag. Tag schema produced by `docker/metadata-action`: `latest` (on main), `v3.17.490` (on tag), `3.17.490` (semver clean), `3.17` (major.minor), `sha-abc1234` (every build). Layer cache via `type=gha,mode=max`. Defaults to `linux/amd64`; an `arm64` line is commented in for installs that need it. **Operator UX (`Makefile`).** Common operations are one-word: `make docker-up`, `make docker-logs`, `make docker-shell`, `make docker-migrate`, `make docker-createsuperuser`, `make dev-up`. Plus `make backup` / `make restore FILE=...` that pipe `mariadb-dump` through the `db` container. **Config template (`.env.example`).** Replaces the older 55-line template. Every supported variable now documented with a one-line "what / why", grouped into Core / Database / Web ports / Bootstrap superuser / SMTP / External tokens / Storage / Beta-signup / Security / Encryption / Docker-image-override sections. Same file is referenced by the classic install path too. **Documentation (`docs/docker.md` + README section).** Full guide covering quick start, profiles, persistent volumes, backups, upgrades, dev mode, and troubleshooting (race conditions, healthcheck timing, ARM hosts, lost `APP_MASTER_KEY` recovery). README gets a `🐳 Run with Docker` block at the top of Quick Start that links to the full guide and explicitly notes the `bash install.sh` path is unchanged. **Build-context hygiene (`.dockerignore`).** Tightened: keeps `docs/` IN (the public roadmap at `/core/roadmap/` reads `docs/ROADMAP.md` at runtime), keeps `mobile/` + `mobile-app/` + `local_apps/play_publish/` OUT (local-only, never shipped to GitHub), keeps `.env` OUT but allows `.env.example` through. No migrations. No mobile rebuild. No effect on existing `bash install.sh` installs. ## [3.17.489] - 2026-05-20 ### Fix #133: M365 sync — mailbox/OneDrive/SharePoint names showing as blank or hex hashes Reported on issue #133: after running an M365 sync against a fresh client tenant, the Mailbox Usage table showed empty Name and UPN columns. Permissions were correct (Reports.Read.All granted, admin consented) — the bug was in how MS Graph returns the data. **Root cause.** Microsoft Graph's reporting endpoints (`getMailboxUsageDetail`, `getOneDriveUsageAccountDetail`, `getSharePointSiteUsageDetail`) anonymize Display Name and User Principal Name **by default** since MS's 2021 privacy update. The columns come back as 32-character hex hashes (e.g. `5C807787F0A30D1C3CDBB76DC1ED3CAB`) or empty strings. The `/users` endpoint is *not* anonymized, so the names exist — they just don't make it into the report rows. **Fix — `integrations/providers/m365.py`:** - New module-level helper `_deanonymize_usage_rows(rows, users)` that detects anonymized rows (blank or 32+ hex chars) and substitutes real values from the users list using UPN as the join key. Falls back to name→UPN lookup if UPN is hashed but the name is real. - `M365Provider.sync()` now calls the de-anonymizer on `mailbox_usage`, `onedrive_usage`, and `sharepoint_usage` before returning. Existing data shape unchanged; only the previously-blank fields get filled in. - Rows that can't be matched (deleted user, both fields hashed) get `_anonymized=True` flagged so the UI can show a hint. **UI hint — `integrations/views.py`:** - The Mailbox Usage card now shows an info banner above the table when any row is still anonymized after the join. The banner links directly to the M365 Admin Center setting that disables anonymization entirely ("Concealed names" toggle at *Settings → Org settings → Reports*) for operators who'd rather flip that than rely on the UPN join. No migration; no env changes; no mobile rebuild. Effect kicks in on the next M365 sync. ## [3.17.488] - 2026-05-14 ### Beta-tester signup: lock Play URLs to canonical listing + forward remote signups upstream Three fixes to the beta-signup pipeline: **1. Visible comment on `/core/beta-test/` (`templates/core/beta_test_signup.html`):** - Removed the multi-line `{# … #}` block I added in v3.17.486. Django's `{# #}` syntax is single-line only — multi-line content after the first newline leaks into the rendered HTML. The comment appeared as visible text above the form on every page load. Fix: just delete it. **2. Play Console URLs locked to the canonical agit8or1 listing (`config/settings.py`):** - `PLAY_OPEN_TEST_URL` and `PLAY_INTERNAL_TEST_URL` were env-driven in v3.17.485, defaulting to empty. That implied each install could point at its own Play listing — but there is exactly ONE Client St0r Android app (the one on agit8or1's Play Console), and every install should direct its users at THAT app's opt-in URLs. - Both URLs are now hard-coded constants in `settings.py`. Removed from the env-override path. Forks running this code automatically use the agit8or1 listing. - `.env` entries for these keys are now dead config but harmless — left in place as a paper trail. **3. Cross-install upstream forwarding (new — `core/views.py` + `core/models.py` migration):** - New `BetaTesterRequest.source_install` field tracks which install a signup came from. - New view `beta_test_upstream` at `POST /core/beta-test/upstream/` accepts cross-origin POSTs from remote installs. CSRF-exempt (no cookie relationship with foreign installs); rate-limited 30/hr per IP via django-ratelimit. Saves the signup with `source_install` set, fires the same admin-notification email as a local signup. - Existing `beta_test_signup` view now best-effort POSTs to `BETA_UPSTREAM_URL` (settings, default `https://huduglue.agit8or.net/core/beta-test/upstream/`) after creating the local row. Net effect: a user on ANY install's `/core/beta-test/` form signs up for the canonical agit8or1 Android app — their signup lands on BOTH the local install (for that operator's records) AND on agit8or1 (so the canonical maintainer can approve and add to Play Console). - Forward is skipped if `BETA_UPSTREAM_URL` is empty (set in `.env` on the canonical install itself) or if the upstream host matches the request host (defense-in-depth against loops). - Forward failures are silent — a network blip on the upstream side can't break the local user's submission flow. Migration: `core/0062_betatesterrequest_source_install.py` — adds the new field plus a couple of index-rename ops that Django auto-detected. ## [3.17.487] - 2026-05-14 ### Play Console demo video scripts — pure-PIL, no external services The two scripts that back the Build buttons on `/play_publish/` (Location demo + Foreground service demo) were originally Selenium + Expo-web-bundle based. That required `npx expo start --web` running on `localhost:8765`, which was never set up on the build host — clicking Build returned `ERR_CONNECTION_REFUSED` immediately. Even after wiring Selenium correctly the moviepy v2 pipeline hung indefinitely at 97% CPU on the FGS variant. Rewrote both: - **`scripts/generate_location_demo_video.py`** — 8 frames of pure-PIL synthetic phone-screen mockups (Dashboard / Settings toggle off / Settings toggle on / Timeclock idle / Timeclock running / Operations log / closing card). Captions overlay each frame. - **`scripts/generate_fgs_demo_video.py`** (new) — 7 frames including a simulated Android notification-shade frame showing the persistent foreground-service notification with the `TYPE: location · FGS` badge — the visual proof Play wants for the FGS_LOCATION declaration. Both scripts now drive `ffmpeg` directly via the `concat` demuxer (bundled with `imageio-ffmpeg`), bypassing moviepy entirely. Output: 24 fps CFR H.264, ~700 KB / ~40s each, build time ~13s on the existing host. No Selenium, no Chrome, no Expo dev server required. Output paths (unchanged): - `local_apps/play_publish/data/builds/location-demo.mp4` - `local_apps/play_publish/data/builds/fgs-demo.mp4` ### Public beta signup form — confirmed generic, fits any install Re-emphasizing the existing setup since people may not realize: `/core/beta-test/` is a **public** form (no login required) suitable for any install of Client St0r to invite their own users into their own Android beta. The relevant env variables are: | Setting | Purpose | Per-install | |---|---|---| | `BETA_ADMIN_EMAIL` | Where signup notifications go | Each install sets their own | | `PLAY_OPEN_TEST_URL` | Public Beta opt-in URL | Each install creates their own Play Console listing and uses its package name | | `PLAY_INTERNAL_TEST_URL` | Internal Testing opt-in URL | Same — Play Console provides this per-app | The form, the navbar CTA, the admin approval page, and the `✉ Email opt-in URL` button all read these settings dynamically — no code changes needed to use this in another instance. ## [3.17.486] - 2026-05-14 ### Beta signup form: solid background card `/core/beta-test/` previously rendered the form directly on the page body, which on profiles using a custom background image looked translucent — text bleeding through, hard to read. Wrapped the whole form in a `.beta-card` with: - White surface in light mode, `#1f2329` in dark mode (both `!important` to override the site-wide `.card` translucency rule) - Soft drop shadow + rounded corners - Opaque form inputs that match the surface - Explicit `backdrop-filter: none` so no blur leaks in from inherited styles Mirrors the same pattern used by the `/play_publish/` dashboard's `.pp-card`. ## [3.17.485] - 2026-05-14 ### Wire BETA_ADMIN_EMAIL / PLAY_OPEN_TEST_URL / PLAY_INTERNAL_TEST_URL through Django settings `core/views.py::beta_test_signup` and `core/views.py::beta_test_admin` read these three keys via `getattr(settings, ...)`, but `config/settings.py` didn't actually pull them from the environment. Net effect on v3.17.484: only the default `BETA_ADMIN_EMAIL` worked; the two URL keys silently stayed empty even when set in `.env`. `config/settings.py` now reads all three via `os.getenv()` so they pick up automatically from the systemd `EnvironmentFile=/home/administrator/.env`. Defaults preserve v3.17.484 behavior (`agit8or@agit8or.net`, empty URLs). ## [3.17.484] - 2026-05-14 ### Beta-tester signup: top-of-page CTA + email notification + one-click opt-in URL email **Top-bar CTA (`templates/base.html`):** - Added a 📱 **Beta app** nav-item visible to logged-in users (currently the CTA only rendered for anonymous visitors). Right side of the navbar between Favorites and the Search box, tooltip "Beta-test the Android mobile app". Routes to the existing `/core/beta-test/` public signup form. **Email notification on signup (`core/views.py::beta_test_signup`):** - Every public submission now fires a `send_mail` to `BETA_ADMIN_EMAIL` (default `agit8or@agit8or.net`). Subject `[Client St0r] Beta tester signup: `, body has name + Google email + company + role + message + approval URL. Fail-open: if SMTP is misconfigured the signup still completes. **One-click opt-in URL email on approval (`core/views.py::beta_test_admin`):** - New action `send_opt_in` on the admin approval page. The "✉ Email opt-in URL" button on each approved row fires a `send_mail` to the tester's Gmail with install instructions + the Play opt-in URL (reads `PLAY_OPEN_TEST_URL` first, falls back to `PLAY_INTERNAL_TEST_URL`), and auto-flips the status to `added_to_play`. The existing "✓ Mark as added" button stays as a manual fallback for when the operator paste-adds the email to Play Console outside the app. **Settings to configure (`config/settings.py` or environment):** - `BETA_ADMIN_EMAIL` — defaults to `agit8or@agit8or.net` - `PLAY_OPEN_TEST_URL` — paste once Open Testing track is live (e.g. `https://play.google.com/apps/testing/com.clientstor.mspreboot`) - `PLAY_INTERNAL_TEST_URL` — current Internal Testing opt-in URL (used until Open Testing is live) - `DEFAULT_FROM_EMAIL` — sender address (any existing config carries over) No mobile rebuild needed for this release. ## [3.17.483] - 2026-05-14 ### Play Store: upload script now sets release name to match the bundle The Play Developer API's `edits().tracks().update()` call doesn't auto-populate `release.name` from the bundle's `versionName` — if you omit it, Play Console carries forward whatever name the previous release had. After uploading v3.17.482's bundle (versionCode 3170482) on top of an earlier release named "3.17.481", the Play Console UI displayed the release as "3.17.481" even though the contained bundle was 3170482. Tester installs were correct; only the human-readable label was stale. **Fix — `/home/administrator/local_apps/play_publish/scripts/upload-aab.py`:** - Now derives `release_name` by regex on the AAB filename (`clientst0r-v(N.N.N).aab` per build-aab.sh's naming convention) and passes it as the release's `name` field on every track update. Falls back to the bundle's versionCode if the filename doesn't match the pattern. - The script lives in the gitignored `local_apps/` tree per the 2026-05-14 GitHub scrub; this changelog entry documents the change for history but the diff itself stays off-repo. No mobile rebuild needed for this release — the only file that changed is the upload helper script, and it runs on the server before the AAB is sent to Google. ## [3.17.482] - 2026-05-14 ### Settings → Updates: stop hammering GitHub on every page load The Settings → Updates page polled `api.github.com/repos/.../tags` on every load with no caching. After the 2026-05-14 history scrub + force-push, the repeated force-pushes consumed the anonymous 60-req/hour quota and the page started showing "GitHub API rate limit exceeded" indefinitely. **Fix — `core/updater.py::UpdateService.check_for_updates()`:** - Now caches the successful poll result in Django's cache backend for 5 minutes (key `system_update_check`) so the page can be open in many tabs without spamming GitHub. Even fully anonymous (no token), this keeps us at 12 req/hour max — well under the 60/hour anonymous limit. - Also caches a 24-hour "last good" snapshot (key `system_update_check_last_good`). When a 403 / rate-limit response comes back, the updater returns the stale-but-recent snapshot with a `stale: true` flag so the UI keeps rendering instead of erroring out. - Rate-limited responses are cached for 10 minutes to prevent retry storms. - New `force_refresh=True` kwarg lets the "Check for updates" button bypass the cache when the user explicitly asks. **Operational fix — `/home/administrator/.env`:** - Appended `GITHUB_TOKEN=...` (existing developer token, granted to the same Google account that owns the repo). The updater already supported this env var; it just wasn't set. Adds a 5000-req/hour authenticated quota as a backstop to the caching layer. ## [3.17.481] - 2026-05-14 ### Mobile beta release prep: public onboarding page, master Play Store guide, full Data Safety + listing copy **Server: public `/beta-onboarding/` page (`core/views.py`, `config/urls.py`):** - New `beta_onboarding()` view following the same pattern as `privacy_policy()` — renders `docs/BETA_ONBOARDING.md` server-side via the `markdown` library. Anonymous-accessible (no auth required) so Play Open Testing users who follow the opt-in URL can read install instructions, known limitations, and feedback channels without first creating an account. - Template at `templates/core/beta_onboarding.html` mirrors the privacy-policy template styling with a CTA button. **Docs:** - `docs/PLAY_STORE_BETA.md` (new) — single canonical walkthrough for every step from local AAB build through Open Testing rollout. Phases 0–10 cover pre-flight, build, account setup, dashboard checklist, store listing, Internal/Closed/Open promotion, monitoring, and recovery ops. Includes a "Known failure" subsection for the current `expo-modules-core@1.12.26` Gradle publishing issue. - `docs/BETA_ONBOARDING.md` (new) — source of `/beta-onboarding/` page content. - `docs/PLAY_STORE_LISTING.md` (updated) — full description revised to match v3.17.481 feature set (vault edits, ticket billing rollup, schedule-on-calendar, asset ↔ vault linking) and to honestly disclose location + photo collection. - `docs/PLAY_DATA_SAFETY.md` (updated) — declares the new optional Sentry crash-reporting SDK; updated header to point at v3.17.481 / versionCode 3170481. **Mobile-side (local-only since the 2026-05-14 history scrub):** - `app.json` — bump to v3.17.481 / versionCode 3170481; explicit `android.permissions` allowlist + `blockedPermissions` list to strip 4 unused / dangerous permissions (`RECORD_AUDIO`, `SYSTEM_ALERT_WINDOW`, `READ_EXTERNAL_STORAGE`, `WRITE_EXTERNAL_STORAGE`). - `app/_layout.tsx` — Sentry SDK init wrapped in try/dynamic-require so missing `@sentry/react-native` is a no-op in dev builds. DSN comes from `EXPO_PUBLIC_SENTRY_DSN`. - `src/components/BetaBanner.tsx` (new) + mount in `_layout.tsx` — orange ribbon at the top of every screen showing version + "Send feedback →". Hidden unless `EXPO_PUBLIC_BUILD_CHANNEL=beta`. Tapping opens the new-ticket form with the subject pre-filled. - `app/tickets/new.tsx` — accepts `?subject=` query param so the banner can deep-link to a pre-filled feedback ticket. **Build status:** local `./gradlew bundleRelease` is currently blocked on a `Could not get unknown property 'release'` error in `expo-modules-core@1.12.26`'s Gradle plugin (AGP 8 publishing-API change). Documented in `docs/PLAY_STORE_BETA.md` with three resolution paths (patch the plugin, upgrade Expo SDK, or use EAS Cloud build). All other Play-Store prep is complete. ## [3.17.480] - 2026-05-13 ### Mobile: asset editing + asset ↔ vault linking **Mobile asset detail (`mobile/app/assets/[id].tsx`):** - New **Edit asset** action on the identity card (gated on `assets_edit`). Inline form for: name, hostname, IP, MAC, asset tag, serial, manufacturer, model, OS name/version, notes. Save round-trips through PATCH and refreshes the detail view. - **Stored secrets** card gains a **+ Link vault entry** action. Opens a searchable modal listing every Vault Password in the asset's organization that isn't already linked. Tapping a row creates the relation server-side. - Each linked vault row gains an **Unlink** button (red, small) right next to the password-type pill. One tap removes the `PasswordRelation` row; the secret itself stays intact. **Backend (`api_mobile/views_assets.py`):** - `PATCH /api/mobile/v1/assets//` — edit whitelisted fields (gated on `assets_edit`). `organization_id` is intentionally rejected on the edit path so re-orging an asset doesn't silently break its `PasswordRelation` rows. - `POST /api/mobile/v1/assets//vault-links/` — body `{password_id}`. Creates a `PasswordRelation(relation_type='asset', relation_id=asset_id)` row. Refuses cross-org links: the password must belong to the asset's org. - `DELETE /api/mobile/v1/assets//vault-links/?password_id=N` — removes the relation. Idempotent. - `/auth/me/` permissions map gains `assets_view`, `assets_create`, `assets_edit`, `assets_delete`. Tests: `MobileAssetEditTests`, `MobileAssetVaultLinkTests` cover the PATCH whitelist, perm gate, cross-org link rejection, and link/unlink round-trip. versionCode 3170479 → 3170480. **AAB rebuild required.** ## [3.17.479] - 2026-05-13 ### Mobile: vault editing + ticket billing rollup + schedule-on-calendar **Mobile vault (`mobile/app/vault/`):** - New `/vault/new` screen — create a vault item from the app. Required: organization, title, password. Optional: username, URL, notes, category. Gated on `vault_create`. Plaintext password is held in component state only and wiped on unmount; the server encrypts via `Password.set_password()`. - `/vault/` gains **Edit** and **Rotate password** actions (gated on `vault_edit`). Edit covers title/username/URL/notes inline. Rotate prompts for a new plaintext in a modal and re-encrypts the ciphertext server-side. Both emit audit-log rows with `channel: mobile`. **Mobile ticket detail (`mobile/app/tickets/[id].tsx`):** - New **Billing** card above the time-entries list: three big totals (Total / Billable / Non-billable) for the ticket. When the client has an active contract (any type), surfaces the contract name + type plus, for block-hours contracts, a progress bar of `used / remaining / total` minutes. Bar tone goes green → amber → red as the bucket empties. - Backend (`api_mobile/views_tickets.py`): `_serialize_ticket(detail=True)` now returns `total_minutes`, `billable_minutes`, `non_billable_minutes`, `resolution_due_at`, and a `contract` sub-object (`{id, name, type, total_minutes, used_minutes, remaining_minutes}`). PATCH also accepts `resolution_due_at` / `due_at` so users can schedule a ticket on a calendar day. **Mobile calendar — schedule on this day (`mobile/app/dispatch/calendar.tsx`):** - New **+ Schedule on this day** button on the selected-day section. Opens a modal: client picker, title, description, priority chips. Submits to the new `POST /api/mobile/v1/dispatch/tasks/` endpoint which creates a `ScheduledTask` anchored at 09:00 local on the selected date and auto-assigns the caller (`TaskAssignment` row). **Backend (`api_mobile/views_vault.py`, `views_dispatch.py`, `views_auth.py`):** - `POST /api/mobile/v1/vault/` (create — gated on `vault_create`). - `PATCH /api/mobile/v1/vault//` (edit + rotate — gated on `vault_edit`; only-rotate sets a fresh ciphertext via `set_password()`). - `POST /api/mobile/v1/dispatch/tasks/` (create scheduled task). - `/auth/me/` permission map gains the five `vault_*` flags so the mobile UI can gate Edit / New / Rotate / Reveal-password affordances. versionCode 3170478 → 3170479. **AAB rebuild required.** ## [3.17.478] - 2026-05-13 ### Mobile dashboard: 7-day agenda strip mixing scheduled tasks + tickets **Mobile dashboard (`mobile/app/dashboard.tsx`):** - New **Upcoming** card under the three-tile row showing the next 7 days. Each non-empty day renders the date label ("Today" / "Tomorrow" / "Mon 5/19" …), a count badge, and up to three item titles with `+N more` for the rest. Tapping the card opens the full month at `/dispatch/calendar`. - Items mix scheduled-task assignments (the caller's `TaskAssignment` rows due that day) and tickets (the caller's accessible tickets with a `resolution_due_at` due that day). Each row carries an emoji prefix — 📋 for tasks, 🎫 for tickets — so the source is glanceable. **Backend (`api_mobile/views_dispatch.py`):** - `GET /api/mobile/v1/dispatch/calendar/?month=YYYY-MM` now folds tickets into the per-day buckets alongside tasks. Rows carry a `kind` discriminator (`'task'` or `'ticket'`) and ticket rows alias `resolution_due_at` to `task_due_date` so existing calendar UI keeps rendering without branching. - New `GET /api/mobile/v1/dispatch/upcoming/?days=N` returns an N-day (default 7, clamp [1, 14]) ordered list with empty days included. Feeds the dashboard agenda strip. **Mobile dispatch calendar (`mobile/app/dispatch/calendar.tsx`):** - Selected-day list now renders both tasks and tickets, distinguished by emoji + tap target. Tickets route to `/tickets/`; tasks route to `/dispatch` (matches prior behavior). versionCode 3170477 → 3170478. **AAB rebuild required.** ## [3.17.477] - 2026-05-13 ### Mobile: client picker on new ticket, assignee picker on detail, three-tile dashboard, vehicles unscoped, ticket permissions **Mobile new-ticket form (`mobile/app/tickets/new.tsx`):** - Replaced the free-text "Organization ID" input with a real searchable client picker (modal sheet over `/api/mobile/v1/organizations/`). Client selection is now **required** — submit is disabled and the field is highlighted until one is chosen. Backend has always required `organization_id` on POST; the old UI just left the user no way to set it short of memorizing numeric IDs. **Mobile ticket detail (`mobile/app/tickets/[id].tsx`):** - New "Assignee" card. Surfaces the current assignee plus three actions: **Claim** (self-assign — always allowed), **Reassign…** (gated on `tickets_assign` permission), and **Clear** (unassign — gated on `tickets_assign`). - Reassign opens a searchable user picker fed by the new `/api/mobile/v1/users/assignable/` endpoint. Users without the assign perm only see themselves in the list (so a tech can claim, but can't hand work off). **Mobile dashboard (`mobile/app/dashboard.tsx`):** - Replaced the prior "Needs attention" + "Today" rows with a single tighter row of three StatTiles: **Critical / Open (New) / Open**. Each tile links to the matching `/tickets?filter=` view. The new `new` filter chip maps to PSA tickets sitting in the `new` status slug (un-triaged). **Mobile vehicles — fleet is shared (`api_mobile/views_vehicles.py`):** - `GET /vehicles/` now returns the entire active fleet for any authenticated user instead of only vehicles with an active `VehicleAssignment` row for the caller. Detail / fuel / damage endpoints likewise dropped the per-assignment gate. Driver-of-record on each log entry (`fuel.user`, `damage.reported_by`) is still set from the request user. Service vehicles aren't multi-tenant — this is the operator's own fleet — so per-user gating was friction without security value. **PSA ticket permissions (`accounts/models.py`, migration `accounts/0033_add_ticket_permissions.py`):** - New `RoleTemplate` flags: `tickets_view`, `tickets_create`, `tickets_edit`, `tickets_assign`, `tickets_view_all`, `tickets_close`, `tickets_delete`. Backfilled the seven shipped system templates (Owner / Administrator / Editor / Help Desk / IT Manager / Documentation Writer / Read-Only) plus the six MSP sample roles (Client / Client Admin / Technician / Tech Manager / Office Manager / Full Admin) with sensible defaults — managers + admins get `tickets_assign`, techs / help desk can file + work but not reassign, read-only and docs writer get view-only. - `api_mobile/views_tickets.py`: POST now gated on `tickets_create`; PATCH `assigned_to_id` requires `tickets_assign` unless the caller is self-claiming or clearing the assignee. `tickets_view_all` unlocks cross-org reads in list + detail. The ticket serializer now returns `organization_name`, `assigned_to_name`, `number`, and `updated_at` so list rows render without a second round trip. - `api_mobile/views_auth.py`: `/auth/me/` payload now includes a `permissions` map (the seven `tickets_*` booleans) so the mobile app can gate UI affordances without re-querying. - `api_mobile/views_users.py` (new): `GET /users/assignable/` — returns the caller alone if they lack `tickets_assign`; otherwise the pool of active users sharing at least one active org membership (or every active user for `tickets_view_all` / superusers). - `api_mobile/views_dashboard.py`: dashboard response now includes `new_tickets` (count of open tickets in the `new` status slug). versionCode 3170476 → 3170477. **AAB rebuild required** — adds the new ticket-creation / detail UI, three-tile dashboard, and vehicle list expansion. Backend migration `accounts/0033_add_ticket_permissions.py` must run on apply. ## [3.17.476] - 2026-05-12 ### Mobile: asset detail full info + vault linkage + tickets filter fix **Asset detail screen (`mobile/app/assets/[id].tsx`):** - Renders the full asset record across four cards: Identity (org, hostname, IP, MAC, asset tag, serial, warranty pill), Hardware & software (manufacturer, model, OS, CPU, RAM, storage, firmware), Lifecycle (purchase date, warranty expiry, lifespan, primary contact), Metadata (created/updated/asset ID). - New "Stored secrets" card lists every Vault entry linked to the asset via `PasswordRelation` (relation_type=`'asset'`). Each row is tappable and routes to `/vault/` for the full reveal flow. - Notes are surfaced in their own selectable card. **Server (`api_mobile/views_assets.py`):** - `_serialize_asset(detail=True)` now returns the wider field set: `organization_name`, `cpu`, `ram_gb`, `storage`, `firmware_version`, `firmware_latest`, `purchase_date`, `warranty_expiry`, `lifespan_years`, `notes`, `primary_contact_name`, `updated_at`. - `asset_detail_view` now joins `select_related('organization', 'primary_contact')` and appends `vault_entries` (id, title, username, url, password_type — no encrypted blob) filtered by accessible orgs. **Mobile tickets list filter bug:** - `useTickets` (`mobile/src/api/tickets.ts`): the **Mine** and **Critical** chips now also send `status=open` to the server. Before, those filters only narrowed by assignee or priority, so closed-but-critical tickets and closed-but-mine tickets would still appear. The explicit **Closed** chip is still the only way to see terminal-status tickets. versionCode 3170475 → 3170476. **AAB rebuild required** — mobile screen + API client changes ship in the bundle. ## [3.17.475] - 2026-05-12 ### Mobile app supports auto-rotation (portrait + landscape) `mobile/app.json::expo.orientation` was `"portrait"`, locking the app to portrait regardless of device rotation. Changed to `"default"` so the OS controls orientation — the app now rotates with the device on phones and tablets. versionCode 3170474 → 3170475. **AAB rebuild required** — orientation is set in the native manifest, not in JS. ## [3.17.474] - 2026-05-12 ### Fix dashboard-critical click + add org filter dropdown to Vault / Docs / Assets **Bug — dashboard "Needs attention: N critical" → tickets list shows nothing:** - Dashboard counts `critical_tickets = priority__code='P1'` — those are the P1 rows. - Tile click goes to `/tickets?filter=critical`, mobile translates that to `?priority=critical` on the server. - Server filtered by `priority__code='critical'` exact match — no priority row has code "critical" (codes are P1..P4) — so zero results. - Fix: `views_tickets.ticket_list_view` now maps the friendly priority labels (`critical`/`urgent`/`high`/`medium`/`normal`/`low`) to their P-codes before filtering, same logic the PATCH path got in v3.17.450. Also matches priority `name` for backends that use English names instead of P-codes. **New `OrgPicker` mobile component (`mobile/src/components/OrgPicker.tsx`):** - Dropdown-style picker — Pressable trigger shows the current selection ("All organizations" / specific org name / "Global only"), tapping opens a modal sheet with: - Search box (so MSPs with 50+ clients can filter by name) - "All orgs" entry at top - Optional "Global only" entry (used by KB) - Full list, scrollable - Selected row highlighted with a ✓ - Hides itself entirely when the user has access to ≤1 org. - Replaces the chip row that was on the Assets list (chips don't scale past ~6 orgs). **Wired into:** - `mobile/app/assets/index.tsx` — replaced horizontal chip scroll with OrgPicker. - `mobile/app/vault/index.tsx` — new OrgPicker above the search field. `useVaultEntries` now takes an `organization_id` arg. - `mobile/app/kb/index.tsx` — new OrgPicker with `showGlobal` so users can filter to globally-shared docs. `useKBArticles` now takes an `organization_id` arg. **Server:** - `views_kb.kb_list_view` now accepts `?organization_id=` for scoping, and `?organization_id=global` for is_global=True docs only. - `views_tickets.ticket_list_view` accepts friendly priority labels as documented above. versionCode 3170473 → 3170474. **AAB rebuild required** — the OrgPicker component + screen updates ship in the bundle. ## [3.17.473] - 2026-05-11 ### Beta-tester sign-up flow + visible app version (combined with v3.17.472) **Beta tester request form** at `/core/beta-test/`: - Public form (no login). Anonymous visitors submit name + Gmail (the Play Store account on their phone) + optional company / role / message / source. - New `core.BetaTesterRequest` model + migration `0061_betatesterrequest`. - Captures IP + UA at submit for abuse triage. **Top-nav links:** - Anonymous users see a "📱 Beta test the app" link in the right-side nav alongside Login. - Authenticated users get it under Account → Mobile & Browser Access → "Beta test the Android app". - Superusers additionally see "Beta tester requests" → the admin page. **Admin triage** at `/core/beta-testers/` (superuser-only): - Pending list with one-click ✓ Approve / ✕ Reject buttons. - Approved-but-not-yet-added section with a copy-to-clipboard textarea of all approved Gmail addresses — paste straight into Play Console → Internal testing → Testers → email list. - Shows the `PLAY_INTERNAL_TEST_URL` setting (env-configurable) so admin can copy the opt-in URL to share with testers. - "Mark as added" button moves a request from approved → added_to_play once you've pasted the email into Play Console. - Recent rejected + added rows at the bottom for audit. **App version visibility** (was queued as v3.17.472 but the version bump didn't actually land — combined here): - New `GET /api/mobile/v1/version/` anonymous endpoint returns the server's version. - Mobile Settings → About card now shows: App version (marketing), Build number (versionCode / CFBundleVersion), Bundle ID, Server version (fetched live). Card tone flips success/warning based on match. - Tiny build footer at the bottom of every dashboard render: `v3.17.473 · build 3170473 · server v3.17.473 ✓`. Tap → Settings. - Build script now syncs `app.json::expo.version` from `config/version.py::VERSION` (previously only `versionCode` got auto-bumped; the marketing version stayed at `0.1.0`). versionCode 3170472 → 3170473. **AAB rebuild required** for the build footer + version probe to land on the phone. ## [3.17.472] - 2026-05-11 ### Visible build number on the app + server version probe You asked: "how do I know if 3170471 is actually pushed out?" — fair question, the app had no way to tell you. Now it does. **Mobile (`mobile/app/dashboard.tsx`):** - Tiny build footer at the bottom of every dashboard render: `v3.17.472 · build 3170472 · server v3.17.472 ✓`. Tap to jump to the full About card in Settings. The `✓` flips to `⚠` when app and server versions differ. **Mobile (`mobile/app/settings/index.tsx`):** - About card now shows: - **App version** — from `Constants.expoConfig.version` (the marketing version, e.g. `3.17.472`) - **Build number** — from `Application.nativeBuildVersion` (the Android versionCode / iOS CFBundleVersion — this is the canonical "which AAB is this" identifier) - **Bundle ID** — `com.clientstor.mspreboot` so you can confirm you're on the right app - **Server version** — fetched live from the new `/version/` endpoint - Card tone flips to **success** (green border) when they match, **warning** (orange) when they differ. Mismatch text tells the user whether the phone is older (update Play Store) or newer (admin click Apply). **Server (`api_mobile/views_version.py`):** - `GET /api/mobile/v1/version/` — anonymous, returns `{version, version_info, api}`. Anonymous so it works on the login screen too. **Build script (`local_apps/play_publish/scripts/build-aab.sh`):** - Now syncs `app.json::expo.version` with `config/version.py::VERSION` at build time. Previously only `versionCode` got auto-bumped; the marketing version stayed at `0.1.0` forever, which is why the app footer was lying. From v3.17.472 onwards, both fields update on every build. - `mobile/app.json` `version` bumped from `0.1.0` to `3.17.472` in this commit so the change is visible without a build (for the next manual edit). versionCode 3170471 → 3170472. **AAB rebuild required** for the build-footer + Settings card to land on the phone. ## [3.17.471] - 2026-05-11 ### Receipt OCR via the configured LLM (Anthropic / OpenAI / Ollama) Replaces the Cloud Vision-only OCR path. Whatever LLM the user has configured under **Settings → AI** now does the vision extraction. No separate Google Cloud service account needed — if you already have Claude / GPT-4o / Ollama-with-llava working for AI doc generation, receipt extraction works too. **Vision implementations added to `docs/services/llm_providers.py`:** - `AnthropicProvider.extract_receipt_fields()` — multimodal `messages.create` with base64 image block. All Claude 3+ models support vision. - `OpenAIProvider.extract_receipt_fields()` — `/chat/completions` with `image_url` data URL + `response_format: json_object`. Needs a vision-capable model (`gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo`). - `OllamaProvider.extract_receipt_fields()` — `/api/chat` with `images:[b64]` + `format: 'json'`. Needs a vision model (`llava`, `llava-llama3`, `llama3.2-vision`, `bakllava`). - Base class `LLMProvider.extract_receipt_fields()` default returns `success:false` — Moonshot / MiniMax fall through gracefully (those providers are text-only at this point). - Shared `_RECEIPT_SYSTEM_PROMPT` + `_RECEIPT_USER_PROMPT` at module scope so prompt tuning is one edit, not five. - Shared `_parse_receipt_json()` strips ```json fences and slack from model output before `json.loads`. **Configured-provider resolver:** - New `get_configured_provider()` returns an instantiated `LLMProvider` built from `LLM_PROVIDER` + `*_API_KEY` / `*_MODEL` Django settings — same selection logic `AIDocumentationGenerator._init_provider` uses, so receipt OCR and AI doc generation always hit the same backend. **Receipt upload (`api_mobile/views_receipts.py`):** - `_ocr_image_bytes()` removed. Replaced with `_extract_with_llm()` that calls the configured provider first, then falls back to the legacy Cloud Vision path (`views_ocr._run_ocr`) for deployments that wired that up already. - `category` is now passed as `hint` to the provider so a "fuel" upload primes the model to look for gallons / cpg. **Tests (2 new):** - LLM provider returns parsed fields → receipt populated + `VehicleFuelLog` auto-created with the right values. - No LLM configured + no Cloud Vision fallback → receipt still saved with image, just no extraction (`ai_processed=false`). **Operationally:** Apply lands this and receipt uploads start using whatever LLM you've configured. If Settings → AI shows "Anthropic Claude (claude-sonnet-4-5-20250929)" — that's what does the extraction. No env vars to set. versionCode 3170470 → 3170471. ## [3.17.470] - 2026-05-11 ### Receipt upload — server-keeps-image + OCR + auto-create downstream records Reversed the v3.17.465 design. **The mobile app just uploads the receipt photo**; the server keeps the image, OCRs it, parses the structured fields, and auto-creates the appropriate downstream record (`VehicleFuelLog` or `VehicleMaintenanceRecord`) based on category. Endpoints: `POST /api/mobile/v1/receipts/` (multipart) + `GET /receipts/` + `GET /receipts//`. New mobile screen `app/receipts/upload.tsx` with category grid and photo capture. SHA-256 dedup on `image_hash`. `Attachment.ENTITY_TYPES` extended for `vehicle_receipt` / `damage_report` / `fuel_log` (admin-UI cleanup; choices are form-level only). versionCode 3170469 → 3170470. **AAB rebuild required** — the new receipts/upload screen ships in the bundle. ## [3.17.469] - 2026-05-11 ### Patch urllib3 to fix CVE-2026-44432 (Dependabot alert #7) `urllib3 2.6.0–2.6.x` had a decompression-bomb safeguard bypass in two cases on the streaming API: 1. Second `HTTPResponse.read(amt=N)` call when decompressed via the official Brotli library. 2. `HTTPResponse.drain_conn()` after partial decompression. Both could cause excessive CPU/memory consumption (CWE-409) when streaming compressed responses from untrusted sources. CVSS v4 8.9 / High. Fixed in `urllib3 2.7.0`. `requirements.txt` pin changed from `urllib3==2.6.*` to `urllib3>=2.7.0,<3.0`. The Apply flow installs from `requirements.txt`, so the upgrade lands automatically on the next prod update. References: - GHSA-mf9v-mfxr-j63j - CVE-2026-44432 ## [3.17.468] - 2026-05-11 ### Fix issues #130 + #131 **Issue #131 — fresh-install migration fails on MySQL:** Migration `psa.0027_email_message_threading` was raising `MySQLdb.OperationalError: (1071, 'Specified key was too long; max key length is 3072 bytes')` on fresh installs. Root cause: the unique constraint on `(organization_id, message_id)` had `message_id` at `max_length=998`. On MySQL utf8mb4 that's `4 + 998*4 = 3996` bytes — over the 3072-byte InnoDB index limit. Fix: shortened the indexed fields in both the migration and the model: - `EmailMessage.message_id`: 998 → 255 (RFC 5322 allows 998 but real-world Message-IDs are well under 200). - `EmailMessage.in_reply_to`: 998 → 255. - `EmailMessage.subject`: 998 → 512 (display field, never indexed; trimmed for storage hygiene). - `Ticket.last_inbound_message_id`: 998 → 255. Migration 0027 is edited in place. Anyone whose install already got past 0027 successfully (they were on a non-utf8mb4 charset or had `innodb_large_prefix=ON`) is unaffected — their column is already wider than the new schema declares. Fresh installs now complete cleanly. **Issue #130 — Anthropic test 404 + Ollama "Unexpected token '<'":** Two separate bugs: 1. `core/services/api_key_validator.validate_anthropic` hardcoded `claude-3-5-haiku-20241022` for the test call. Anthropic retired that model — every test against a valid key was returning `404 not_found_error`. Updated to `claude-haiku-4-5-20251001` (the current Haiku). 2. `assets.views.asset_ai_doc` had no top-level try/except, so any uncaught exception during AI generation returned Django's HTML 500 page. The frontend `fetch(...).then(r => r.json())` then choked with `Unexpected token '<', " <"...`. Wrapped the entire view body in `_asset_ai_doc_inner` + outer try/except that always returns a JSON error response. **Note:** the Ollama JSON parse error path is now visible — the underlying Ollama provider call (in `docs/services/llm_providers.OllamaProvider.generate`) catches its own errors and returns `{success: False, error: str(e)}`. The JSON now reaches the frontend cleanly. If users still see Ollama failures, the actual error message comes through and we can iterate from there. ## [3.17.467] - 2026-05-11 ### Expanded push triggers + OCR setup scaffolding + signal weak-ref bug fix **Critical bug fix:** the v3.17.463 ticket-assignment push *silently never fired* in production because the `@receiver` decorators inside `_register_*_signals()` produced weak references that got garbage-collected the moment the registration function returned. No test had verified the side effect, so the regression sat undetected. Every `@receiver` in `api_mobile/signals.py` now passes `weak=False`. **Expanded push triggers** (`api_mobile/signals.py`): - `psa.TicketComment` create → push the ticket's assignee, unless the commenter IS the assignee. Internal comments still push (visibility, not audience scope, drives the rule). - `scheduling.TaskAssignment` create → push the newly-assigned user with the task title. - `processes.ProcessExecution` create → push the `assigned_to` user, unless they started the run themselves. - `vault.VaultRevealRequest` `status` transitions to `approved` → push the original requester. - All five receivers funnel through a new `signals._dispatch_push()` helper so tests can patch a single egress point. **Tests** (`MobilePushSignalsTests`, 6 tests): - Ticket comment pushes assignee; doesn't push when author == assignee. - Task assignment pushes user. - Process execution pushes when assigned by someone else; doesn't push for self-starts. - Vault reveal request `pending → approved` pushes the requester. **OCR setup scaffolding:** - `requirements-optional.txt` now includes `google-cloud-vision==3.7.*` with a step-by-step comment block: create GCP service account → download JSON → drop at `/home/administrator/secrets/vision-sa.json` → set `OCR_PROVIDER=cloudvision` + `GOOGLE_APPLICATION_CREDENTIALS=…` in the gunicorn EnvironmentFile → `pip install -r requirements-optional.txt` → restart. - New `GET /api/mobile/v1/ocr/status/` endpoint reports `{configured, provider, sdk_loadable, sdk_error, credentials_env_set}` so you can verify the wiring without trying to OCR a fake image. **Full suite: 117/117 api_mobile tests pass.** ## [3.17.466] - 2026-05-11 ### Test coverage for v3.17.461–465 endpoints + stale-test fix Audit of the completion pass — the four newest endpoints had no test coverage. Added 15 focused tests plus fixed one stale assertion from before v3.17.448. **New tests:** - `MobileScanTests` (6 tests) — QR resolves to inventory; cross-org inventory 404s; asset_tag and serial_number both resolve to asset; unknown code 404; missing `?code=` 400. - `MobileDispatchCalendarTests` (2 tests) — month bucketing groups same-day assignments correctly and excludes out-of-month; `?month=bogus` 400. - `MobileNotificationRegisterTests` (4 tests) — registration creates a `MobileDevice` row; missing token 400; second `register` with the same `device_id` updates in place (idempotent, returns 200 instead of 201); `deregister` flips `revoked` + clears the token. - `MobileOcrEndpointTests` (3 tests) — endpoint 503s when `OCR_PROVIDER` unset; the receipt regex parser extracts the full field set from synthetic OCR text; the parser leaves missing fields out instead of guessing. **Stale-test fix:** - `MobileDashboardTests.test_dashboard_returns_counts` was still checking for `offline_monitors` / `security_alerts_open` / `organization_count` — keys that v3.17.448 renamed or removed. Updated to the current top-level keys; the exhaustive shape check still lives in `MobileDashboardShapeTests`. **Minor:** - `views_ocr._STATION_RE` now tolerates leading whitespace in OCR'd receipt text (`^\s*(SHELL|BP|...)`). Without this, indented OCR output failed station-name matching. **Full suite:** 110/110 api_mobile tests pass. ## [3.17.465] - 2026-05-10 ### Receipt OCR pre-fill for fuel logs (opt-in via env) Fuel form auto-fills gallons / $-per-gallon / station from a receipt photo when the server-side OCR is configured. Off by default — endpoint returns 503 unless `OCR_PROVIDER` env is set, so the mobile app degrades silently to manual entry. **Server (`api_mobile/views_ocr.py`):** - `POST /api/mobile/v1/ocr/receipt/` (multipart) — accepts a `photo` file. - Routes to Google Cloud Vision when `OCR_PROVIDER=cloudvision` + `GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa.json` is set in env (the SDK is imported lazily so unset deployments don't pay the import cost; install with `pip install google-cloud-vision` to enable). - Best-effort regex parser pulls `gallons`, `cost_per_gallon`, `total_cost`, `station`, `date_raw` from US-format fuel receipts. Returns only fields it actually matched — missing fields stay missing so a wrong guess never overwrites a user's data. - Returns full `raw_text` (capped to 2 KB) for debugging the parser against new receipt formats. - 503 `{detail: "OCR not configured"}` when `OCR_PROVIDER` unset. The endpoint is wired but inert until you flip the switch. **Mobile (`mobile/app/vehicles/[id].tsx`):** - After picking a photo for a fuel receipt, fires `/ocr/receipt/` in the background. On 200, the parsed values pre-fill any currently-empty fields (gallons / $/gal / station). On 503 / failure / network error: silent, manual entry continues. - Damage photos do **not** run OCR — only fuel. **Adding another provider** (Textract, Tesseract, etc.): add a branch in `_run_ocr()`. Keep the same response shape (`{extracted: {gallons, cost_per_gallon, station, ...}, raw_text}`) and the mobile UI just works. **Public release checklist updated** — the "Deferred" section now reflects that all four previously-deferred features (#21–24) shipped, with their per-feature setup requirements (env vars, Play Console video, etc). versionCode 3170464 → 3170465. --- ### v3.17.453 → 465 — completion pass summary Thirteen versions, ~5500 lines added across server + mobile, 70+ tests. | ver | thread | |---|---| | 453 | Vault Fernet decrypt fix | | 454 | Asset create + ticket time entry + asset org filter | | 455 | Workflow (Process) runner | | 456 | Vehicles + fuel + damage | | 457 | Dispatch board + task sign-off | | 458 | Inventory + transactions | | 459 | Dashboard reorganization + visual polish + workflow stage links | | 460 | Photo capture (damage + fuel) | | 461 | QR/barcode scanner + Play Console release docs | | 462 | Dispatch calendar view | | 463 | Push notifications via Expo | | 464 | Background location (opt-in) | | 465 | Receipt OCR pre-fill (opt-in via env) | Repo state: all originally listed user requirements and all "deferred" follow-ups are now in `main`. Push: `https://github.com/agit8or1/clientst0r`. ## [3.17.464] - 2026-05-10 ### Background location tracking (opt-in) — Sub-phase 8.2 completes The deferred Sub-phase 8.2 (background GPS for shift visit logging) ships. **Off by default**, explicit opt-in via Settings, foreground service with visible notification while running. **Mobile:** - New dep: `expo-task-manager ~11.8.2`. `expo-location` plugin block now declares `isAndroidBackgroundLocationEnabled` + `isIosBackgroundLocationEnabled`. Permission strings updated to be honest about background usage. - New `mobile/src/utils/backgroundLocation.ts`: - `defineTask(BG_LOCATION_TASK)` at module scope so the OS-wake handler is registered before any background event fires. - `enableBackgroundLocation()` requests foreground → then background permission (errors clearly if denied), starts `Location.startLocationUpdatesAsync` with `timeInterval=5min`, `distanceInterval=50m`, Balanced accuracy, foreground service notification "Client St0r is tracking your shift." - `disableBackgroundLocation()` stops the task and clears the AsyncStorage flag. - State persisted in AsyncStorage so the toggle survives app restarts. - Settings screen gains a "Background location" card with explanatory note + Turn on / Turn off button. The card flips to a green-tone (success border) when active. - The task posts `{lat, lon, accuracy, timestamp}` to the existing `POST /locations/` endpoint — server already drops off-shift pings per `WorkingHours` (v3.17.410 behavior). **Docs:** - `docs/PRIVACY_POLICY.md`: new paragraph explicitly explaining background tracking (off by default, 5-min cadence, foreground service notification, off-shift pings dropped at server). Permission table adds `ACCESS_BACKGROUND_LOCATION`, `POST_NOTIFICATIONS`, `FOREGROUND_SERVICE_LOCATION`. - `docs/PLAY_DATA_SAFETY.md`: precise-location section updated for the optional background flow with the wording Play wants in the free-text justification field. - `docs/PUBLIC_RELEASE_CHECKLIST.md`: flagged the **High** Play Console review impact — production submission will require a 30-second sample video of the in-app opt-in flow and an "Allowed by Google" declaration. versionCode 3170463 → 3170464. ## [3.17.463] - 2026-05-10 ### Push notifications via Expo End-to-end pipeline. Device registers an Expo push token on login, server fires a push when a ticket gets reassigned to that user, tap routes to the ticket. **Server:** - New migration `field_ops/migrations/0006_mobiledevice_expo_push.py` adds `expo_push_token` (CharField, max 200) and `notifications_enabled` (BooleanField, default True) to the existing `MobileDevice` model. - New `api_mobile/push.py::send_push_to_user(user, title, body, data)` — fires a fire-and-forget HTTP POST to `https://exp.host/--/api/v2/push/send` on a background thread. Never blocks the caller. Sends to every active, opted-in device for the user. - New `api_mobile/views_notifications.py`: - `POST /notifications/register/` accepts `{token, platform, device_id?, name?, enabled?}` and upserts a `MobileDevice`. Idempotent. - `POST /notifications/deregister/` marks the device revoked + clears its token. - New `api_mobile/signals.py` registers `pre_save`/`post_save` on `psa.Ticket`. When `assigned_to_id` changes (or a new ticket is created with an assignee), fires `send_push_to_user(new_assignee, ...)`. Catches assignments from web, mobile, integrations — anywhere `Ticket.save()` runs. - `api_mobile/apps.py::ready()` registers the signals on app start. Wrapped so a failure can't block startup. **Mobile:** - New dep: `expo-notifications ~0.28.18`. Plugin block in `app.json` with icon + tint color. - New `mobile/src/utils/push.ts` — `registerForPushNotifications()` requests permission, fetches the Expo push token via `Notifications.getExpoPushTokenAsync()`, POSTs to `/notifications/register/` with a stable client-side UUID device_id (stored in AsyncStorage so re-logins update the same device row). `deregisterPushNotifications()` mirrors it. - Login + MFA success in `src/api/auth.ts` fires registration. Logout fires deregistration. Both are fire-and-forget — push is optional, login/logout never block on it. - `_layout.tsx` registers a `Notifications.addNotificationResponseReceivedListener` that reads `data.route` from the payload and `router.push`es to it on tap. Server attaches `route: '/tickets/'` to ticket-assignment pushes. **No FCM project setup required.** Expo's relay handles FCM (Android) and APNS (iOS) using the project's existing EAS credentials — no GoogleService-Info.plist / google-services.json needed. versionCode 3170462 → 3170463. ## [3.17.462] - 2026-05-10 ### Dispatch calendar view Month-grid calendar of the caller's scheduled-task assignments. Tap a day → see that day's assignments in a list below. Server returns a flat `{YYYY-MM-DD: [assignments]}` map for the requested month so the grid can render dot-counts without per-day requests. **Server (`api_mobile/views_dispatch.dispatch_calendar_view`):** - `GET /api/mobile/v1/dispatch/calendar/?month=YYYY-MM` — defaults to current local month. Returns `{month, today, days}` where `days` is keyed by date string and contains the same `_serialize_assignment(a)` payload the board uses (so the tap-through can render the existing assignment card layout). - Empty days are omitted rather than returned as empty lists (smaller payload, simpler client check). **Mobile (`mobile/app/dispatch/calendar.tsx`):** - Hand-rolled month grid (no external calendar library — keeps the AAB lean). 7-col × 5–6-row layout. Today gets a blue border; selected day gets a filled blue background. - Dots per day with assignment count badge (capped at "9+"). - < and > navigate months; state resets to current-day selection when changing months. - "📅 Calendar" button on the dispatch board header opens the screen. versionCode 3170461 → 3170462. ## [3.17.461] - 2026-05-10 ### QR / barcode scanner + Play Console public-release docs **QR / barcode scanner (`mobile/app/scan.tsx`):** - Full-screen camera screen using `expo-camera`'s `CameraView` + `onBarcodeScanned`. Recognizes QR, Code 128 / 39, EAN 13 / 8, UPC A / E. - New endpoint `GET /api/mobile/v1/scan/?code=` (`api_mobile/views_scan.py`) resolves the decoded string in this order, all org-scoped: 1. `InventoryItem.qr_code` (exact) 2. `Asset.asset_tag` (case-insensitive exact) 3. `Asset.serial_number` (exact) 4. `VehicleInventoryItem.qr_code` for vehicles assigned to the caller - On match: server returns `{kind, id, name, route}`; mobile deep-links via `router.replace(route)`. On miss: 404 + the scanner re-arms after 1.5 s with the unmatched code shown. - "📷 Scan" button added to the Inventory and Assets list headers, opening the modal scanner. - Modal presentation in `_layout.tsx` (no header chrome). **Public-release prep:** - New `docs/PUBLIC_RELEASE_CHECKLIST.md` consolidates everything needed for a Play Console production release: visual assets you still need to provide, every form to fill out (with pointers to which doc has which copy), and the sequence to flip from internal-testing to production. - `docs/PLAY_DATA_SAFETY.md` updated to cover **photos** (v3.17.460) and **precise location** (v3.17.452) — both now collected and need declaration. - `docs/PRIVACY_POLICY.md` rewritten "What the app collects" section: explicit treatment of `CAMERA`, `READ_MEDIA_IMAGES` / `READ_EXTERNAL_STORAGE`, and `ACCESS_FINE_LOCATION` purpose strings. Permission table refreshed. versionCode 3170460 → 3170461. **AAB rebuild required** — `expo-camera` plugin needs to land in the manifest via `expo prebuild`. ## [3.17.460] - 2026-05-10 ### Photo capture — damage reports + fuel receipts Damage reports without photos are weak evidence. Fuel logs without receipts can't be reimbursed. Both the damage and fuel POSTs now accept an optional `photo` field via multipart upload. **Server (`api_mobile/views_vehicles.py`):** - Damage and fuel views switch to mixed parsing: `JSONParser`, `MultiPartParser`, `FormParser`. Plain JSON still works; multipart adds the optional photo path. - New `_save_attachment(user, file, entity_type, entity_id)` helper wraps `files.models.Attachment.objects.create`. Vehicles aren't org-scoped, so the attachment is attributed to the uploader's primary accessible org. - `Attachment.ENTITY_TYPES` extended with `damage_report` and `fuel_log`. CharField choices change — no migration needed. - Response payload includes `photo: {id, original_filename, file_size, content_type, uploaded_at}` when a file was attached. **Mobile:** - New deps: `expo-image-picker ~15.0.7` + `expo-camera ~15.0.16`. - `app.json` plugins now include both with explicit, honest purpose strings (camera + media library on Android, NSCameraUsageDescription / NSPhotoLibraryUsageDescription on iOS). - New helper `mobile/src/utils/photoPicker.ts` — `takePhoto()` and `pickFromLibrary()` return a `{uri, name, type}` object axios's FormData can append directly. - `useLogFuel` and `useLogDamage` switch to multipart automatically when a photo is attached. - Vehicle detail screen gets "📷 Take photo" / "🖼 From library" buttons + a thumbnail preview (with Remove ✕) on both fuel and damage forms. versionCode 3170459 → 3170460. **AAB rebuild required** — both new deps need to land in the manifest via `expo prebuild`. ## [3.17.459] - 2026-05-10 ### Dashboard reorganization + visual polish User asked for "look nicer + organized dashboard." Restructured the dashboard into clear semantic sections, gave it a real visual hierarchy, and made workflow stage entity links navigable. **Dashboard layout — was a flat scroll of cards, now sections:** 1. **NEEDS ATTENTION** (red, only renders when there's something) — critical tickets + overdue tasks. Big numbers, deep-link onto the right screens. 2. **Shift card** — single horizontal "On the clock / Off the clock" card. Green when active, with hh:mm started + duration. Tap to jump to Timeclock. 3. **TODAY stats row** — 2 hero StatTiles: "My open tickets" and "Today's tasks." Tap-through to filtered list views. 4. **NAVIGATE icon grid** — 8-tile 4×2 grid replacing the bare chip row: Dispatch / PSA / Assets / Vault / Docs / Workflows / Inventory / Vehicle. Each tile has an emoji + label + optional notification badge (e.g. red badge on Dispatch when overdue tasks exist). 5. **RECENT** — recent tickets + recent assets, now in compact-mode cards. **New components:** - `components/Card.tsx` — added `tone` prop (`accent` / `warning` / `critical` / `success`), `compact` mode, new `SectionHeader` export, hero StatTile variant. Tones color the border + tone-aware StatTile values. - `components/NavTile.tsx` — square icon-tile with emoji, label, optional badge. **Tickets list (`mobile/app/tickets/index.tsx`)** now reads `?filter=` from the URL so dashboard deep-links (`/tickets?filter=mine`, `/tickets?filter=critical`) land on the correct filter chip. **Workflow stage entity links navigable:** - Server (`api_mobile/views_workflows.py`): execution-stage payload now includes `linked_password_id` / `linked_asset_id` / `linked_document_id`. - Mobile (`mobile/app/workflows/exec/[id].tsx`): renders linked entities as tappable chips that route to `/vault/` / `/assets/` / `/kb/`. Tech can open a credential the runbook references with one tap, no hunting. versionCode 3170458 → 3170459. ## [3.17.458] - 2026-05-10 ### Mobile inventory (org-scoped) — last of the "PSA in your pocket" pass Wraps the `inventory` app. List items, see low-stock badges, scan/search, adjust stock from the field with an `InventoryTransaction` audit row per change. **Server (`api_mobile/views_inventory.py`):** - `GET /inventory/?search=&item_type=&organization_id=&low_stock=true&page=` — paginated, org-scoped via `accessible_org_ids`. `low_stock=true` filters with `quantity__lte=F('min_quantity')`. Search hits name / sku / manufacturer_part_number / qr_code (exact match for QR). - `GET /inventory//` — detail (404 cross-org). - `GET/POST /inventory//transactions/` — list last 50 / create one. Body: `{transaction_type, quantity_change, notes?}`. Allowed types: `stock_in` / `stock_out` / `adjustment`. `stock_in` auto-coerces to positive, `stock_out` auto-coerces to negative, `adjustment` accepts whatever sign you provide. Atomic: both the `quantity` update and the `InventoryTransaction` insert happen in one transaction. Negative-stock outcomes return 400 instead of writing. **Mobile:** - `app/inventory/index.tsx` — list with low-stock badge, search, "Low stock only" toggle. - `app/inventory/[id].tsx` — detail with prominent quantity display, `+ Stock in` / `− Stock out` / `Adj.` buttons that wire to the same transactions endpoint, recent-transaction history. - `mobile/src/api/inventory.ts` — `useInventory`, `useInventoryItem`, `useInventoryTransactions`, `useAdjustStock`. - "Inventory" tile added to dashboard `NAV_ITEMS`. **Tests:** 9 in `MobileInventoryTests` — own vs other-org isolation (cross-org detail + transactions both 404), low-stock filter, stock_in increments, stock_out auto-negates sign, would-go-negative blocked, invalid type 400, zero change 400. versionCode 3170457 → 3170458. --- ### Pass summary (v3.17.453 → v3.17.458, all shipped 2026-05-10) Six versions, ~3500 lines added across server + mobile, 50+ tests. | ver | thread | |---|---| | 453 | vault Fernet decrypt fallback | | 454 | asset create + ticket time entry + asset list org filter | | 455 | workflows (Process runner) — list, start, complete-stage | | 456 | vehicles + fuel + damage | | 457 | dispatch board + scheduled task sign-off | | 458 | inventory + transactions | Two AAB rebuilds were needed in the chain (versionCode bumps in 454 + 455 + 456 + 457 + 458 — they all need to land at once via the latest AAB). ## [3.17.457] - 2026-05-10 ### Mobile dispatch board What's-on-my-plate view for techs in the field. Combines `scheduling.ScheduledTask` assignments with open ticket assignments. **Server (`api_mobile/views_dispatch.py`):** - `GET /dispatch/` — buckets the caller's task assignments into `overdue` / `today` / `upcoming`, plus tickets `{open_count, recent[5]}`. Bucket logic uses each task's `due_date` against the current local day window. No-due-date assignments fall under `today`. - `POST /dispatch/assignments//ack/` — sign off on a single assignment via the existing `TaskAssignment.sign_off` helper. Body `{notes?}`. Triggers the task's completion check (auto-completes when `require_all_signoffs` is satisfied). Other-user assignments return 404. - `POST /dispatch/tasks//comments/` — add a `TaskComment`. Caller must have an assignment on the task or 404. **Mobile:** - `app/dispatch/index.tsx` — sectioned screen (Overdue / Today / Upcoming / My tickets). Each assignment row shows priority pill, title, org, description, due date. Inline "Sign off" expand-to-form on un-acked items. - `mobile/src/api/dispatch.ts` — `useDispatchBoard`, `useAcknowledgeTask`, `useAddTaskComment`. - "Dispatch" tile added to dashboard `NAV_ITEMS` (front of the row). **Tests:** 5 in `MobileDispatchTests` — buckets, sign-off happy path, other-user assignment 404, comment create, comment on unassigned task 404. versionCode 3170456 → 3170457. ## [3.17.456] - 2026-05-10 ### Vehicle inventory + fuel + damage on mobile Wraps the `vehicles` app for techs in the field. Authorization model: a tech sees and can act on vehicles they have an active `VehicleAssignment` for — vehicles are not org-scoped because they're the company fleet. **Server (`api_mobile/views_vehicles.py`):** - `GET /vehicles/` — vehicles currently assigned to me - `GET /vehicles//` — detail (404 if not assigned) - `GET /vehicles//inventory/` — `VehicleInventoryItem` rows for that vehicle - `GET/POST /vehicles//fuel/` — list / log a fill-up. Creates `VehicleFuelLog`. Required: `mileage`, `gallons`, `cost_per_gallon`. `total_cost` auto-computed if omitted; `date` defaults to today. Updates `ServiceVehicle.current_mileage` if the new reading is higher. - `GET/POST /vehicles//damage/` — list / file a `VehicleDamageReport`. Required: `description`. Severity defaults to `minor`. Captures the vehicle's current condition as `condition_before`. **Mobile:** - `app/vehicles/index.tsx` — my vehicles list - `app/vehicles/[id].tsx` — single screen with: vehicle summary, on-board inventory list (with low-stock badge when `quantity <= min_quantity`), fuel fill-up form + recent log, damage report form + recent reports - `mobile/src/api/vehicles.ts` — typed hooks - Operations hub gets new entries: "My vehicle", "Workflows" (alongside Timeclock) **Tests:** 8 in `MobileVehiclesTests` — own vs other-tech assignment isolation (the cross-tech vehicle returns 404 on detail), inventory list, fuel happy path with auto total_cost + odometer sync, fuel invalid 400, damage create, damage missing-description 400, unauth blocked. versionCode 3170455 → 3170456. ## [3.17.455] - 2026-05-10 ### Workflows on mobile (Process runner) Surfaces the existing `processes` app as a mobile feature. Tech can browse available workflows for their org (plus globals), open one, and start a run that creates a `ProcessExecution` assigned to themselves. From the run screen they tap "Mark done" on each stage; when every stage has a completion row, the execution auto-finishes. **Server (`api_mobile/views_workflows.py`):** - `GET /workflows/` — published, non-archived processes scoped to user's accessible orgs ∪ globals. Search + category + organization_id filters. - `GET /workflows//` — with stages. - `POST /workflows//start/` — creates `ProcessExecution` (status=in_progress, assigned_to=caller). Globals require explicit `organization_id`; org-scoped processes default to their own org. - `GET /workflows/executions/?status=` — caller's executions. - `GET /workflows/executions//` — with stage state (each stage carries `is_completed`, `completed_at`, etc.). - `POST /workflows/executions//stages//complete/` — idempotent. Auto-completes the execution when all stages are done. **Mobile:** - New screens: `app/workflows/index.tsx` (library + my in-progress runs), `app/workflows/[id].tsx` (process detail + start), `app/workflows/exec/[id].tsx` (run detail with per-stage Mark done buttons + notes). - `mobile/src/api/workflows.ts` — typed hooks `useWorkflows`, `useWorkflow`, `useStartWorkflow`, `useMyExecutions`, `useExecution`, `useCompleteStage`. - "Workflows" tile added to dashboard `NAV_ITEMS`. **Tests:** 5 in `MobileWorkflowsTests` — list visibility, detail with stages, start creates execution, complete-all auto-finishes, idempotent stage complete. versionCode 3170454 → 3170455. ## [3.17.454] - 2026-05-10 ### Asset create from mobile + ticket time logging + asset list org filter Three coupled mobile additions plus their server endpoints. Bundled into one AAB rebuild as v3.17.454. **Asset creation from the field (`POST /api/mobile/v1/assets/`):** - `views_assets.asset_list_view` now also handles POST. Required: `organization_id` (must be accessible — 403 otherwise) and `name`. Optional fields whitelisted: `asset_type`, `asset_tag`, `serial_number`, `hostname`, `ip_address`, `mac_address`, `os_name`, `os_version`, `manufacturer`, `model`, `notes`. Anything else is dropped. - New mobile screen at `mobile/app/assets/new.tsx` — org chip picker, identity card (name + asset_type chips), network card (hostname + IP), hardware card (serial + model), notes. "+ New" button on the asset list header opens it. Auto-selects the user's only org if there's one. - 5 tests in `MobileAssetCreateTests`: own-org create, cross-org 403, missing org 400, missing name 400, unknown fields silently dropped. **Ticket time logging (`POST /api/mobile/v1/tickets//time/`):** - New `views_tickets.ticket_time_view` accepts both shapes: `{duration_minutes, notes?, is_billable?}` for manual entries (sets `started_at = now - duration`), or `{started_at, ended_at?, notes?, is_billable?}` for explicit ranges (computes duration). 404 on cross-org. - GET on the same path returns the most recent 50 entries. - Mobile ticket detail (`tickets/[id].tsx`) gets a new "Time" card above Comments — minutes input, optional notes, billable toggle, list of prior entries. - 5 tests in `MobileTicketTimeEntryTests`: by-duration, by-range, missing inputs 400, list, cross-org 404. **Asset list filter by organization:** - Horizontal chip row above the asset list, sourced from `/organizations/`. "All orgs" + one chip per org. State driven; server already supported `?organization_id=`. Hidden when the user has access to ≤ 1 org. versionCode 3170452 → 3170454. ## [3.17.453] - 2026-05-10 ### Vault decrypt: handle legacy Fernet entries Five vault entries on prod (id 103-107, all in one org) returned 500 "Failed to decrypt password." on reveal. Root cause: those rows were written by an older code path that called `cryptography.fernet.Fernet.encrypt` directly. Fernet emits URL-safe base64 (`-` and `_`); the current decrypt path uses standard `base64.b64decode` which rejects those characters and raises `binascii.Error: number of data characters (97) cannot be 1 more than a multiple of 4` — surfacing as a generic 500 in the mobile reveal flow. **Fix:** - `vault.encryption.decrypt` now detects the Fernet token signature (`gAAAAA` prefix = URL-safe base64 of the 0x80 version byte) and decrypts via `cryptography.fernet.Fernet` using the same 32-byte master key, just URL-safe-base64-wrapped. - `vault.encryption_v2.decrypt_v2` short-circuits to the v1 path when it sees the same signature, instead of trying its own `base64.b64decode` and raising before the fallback can fire. - Web vault, mobile vault, and any other consumer of `Password.get_password()` now decrypt these entries cleanly. 3 tests in `vault.tests.LegacyFernetDecryptTests`: v1 direct, v2 routing, end-to-end through `Password.get_password()`. All use a runtime-generated Fernet token from the configured master key, so the test passes regardless of which key the test environment uses. Server-only fix; ships via Apply, no AAB rebuild. ## [3.17.452] - 2026-05-09 ### GPS-attached clock-in + warn-but-allow geofence enforcement (Sub-phase 8.2 / 8.3) Tech clocks in from a phone, server now decides if that fix is inside any active `ClientSiteGeofence` for the destination org. Outside-fence clock-ins still succeed (warn-but-allow per the user's policy choice) but the response carries `geofence_override: true` so the mobile UI can surface a yellow banner and the audit log captures who clocked in where, with what GPS accuracy, and which fence (if any) matched. **Server (`api_mobile/views_field_ops.clock_in_view`):** - Body now optionally accepts `lat`, `lon`, `accuracy`. Bad numeric values return 400 (instead of silently dropping) since the client deliberately attached them. - After the entry is saved, if the org has at least one active `ClientSiteGeofence`, walks them and calls `fence.contains(lat, lon)` (existing equirectangular / ray-cast helper). First match short-circuits. - Response `geofence_override` is `true` only when at least one active fence existed AND none matched. No fences → no override (an org without geofences can't be "outside" anything). - Audit log entry now includes `gps_provided`, `gps_accuracy_m`, `geofence_match_id`, `geofence_override` so override patterns are queryable. **Mobile (`mobile/app/timeclock/index.tsx` + `src/api/timeclock.ts`):** - `expo-location` (~17.0.1) added to `package.json`. - `app.json` plugins now include `expo-location` with explicit purpose strings (Android `ACCESS_FINE_LOCATION`, iOS `NSLocationWhenInUseUsageDescription`) — phrased to be honest with the Play Console reviewer: location is only captured at clock-in time, not background. - Timeclock screen requests foreground permission on tap. Best-effort: permission denial / GPS off / capture timeout all degrade gracefully — clock-in succeeds without coords, just no geofence verification. - New `ClockInResult` type extends `TimeclockEntry` with `geofence_override` + `geofence_match_id`. - Yellow warn banner renders for ~one screen render when override fires. Auto-clears on next clock-in attempt. **Tests:** - 5 new in `MobileFieldOpsTests`: inside fence (no override + match_id set), outside fence (override + audit row), no active fence (no override), no GPS (no override), invalid GPS (400). **versionCode 3170451 → 3170452** in `mobile/app.json`. Mobile-only AAB rebuild required for the location permission to be requested at install (declared in the manifest by `expo prebuild` once `expo-location` plugin is present). This delivers the GPS-auto-time slice of Sub-phase 8.2 (foreground capture at user-initiated clock-in only) and the timeclock-with-context slice of Sub-phase 8.3. Background auto-time + always-on GPS pings remain deferred. ## [3.17.451] - 2026-05-09 ### Mobile cleanup pass — remove dead screens, lock down profile, group by org A round of mobile-only changes that need an AAB rebuild + Play Console upload. **Removed:** - `mobile/app/monitoring/` and `mobile/app/security/` — both had no server endpoints (404), and the user opted to remove them rather than build out the missing API surface. - `mobile/src/api/monitoring.ts` and `mobile/src/api/security.ts` — dead hooks. - Operations hub no longer links to either; it's just Timeclock for now. - Dashboard tile row for Expiring soon / Monitors down / Open alerts removed (they pointed at /operations which only has Timeclock now and was misleading). The "Recent security alerts" card on the dashboard is also gone. Server-side `data.security` and `data.monitors_down` are still returned by `/dashboard/` for any other consumer; the mobile just doesn't render them. **Profile is now read-only:** - `mobile/src/api/profile.ts` — switched from `/profile/` (which never existed on the server, would have been 404) to `/auth/me/`. Dropped `useUpdateProfile`. Tolerates either `{user: {...}}` or flat `{...}` response shape. - `mobile/app/settings/index.tsx` — gutted the editable form (TextField for first/last/email + "Save profile" button gone). Profile fields now display as read-only label/value rows. Edits go through the web app. **Assets and vault lists grouped by organization:** - `mobile/app/assets/index.tsx` and `mobile/app/vault/index.tsx` — bucket entries by `organization_name`, render section headers (`ORG NAME · count`) above each bucket. Items with no org fall under a "No organization" section. Sorts alphabetically by org name. **versionCode 3170450 → 3170451** in `mobile/app.json` so Play Console accepts the new AAB. ## [3.17.450] - 2026-05-09 ### Mobile ticket status/priority changes now actually persist The mobile ticket detail screen has had a status picker and priority picker since v3.17.349, and the server PATCH handler accepted them — but only if the body keyed on `status_id` / `priority_id` (FK ints). The mobile client sends `{status: 'open'}` and `{priority: 'critical'}` (friendly strings), so every PATCH silently no-op'd. Tap a status, server returns 200, ticket unchanged. `api_mobile/views_tickets.ticket_detail_view` now also accepts: - `status` (string) — looks up `TicketStatus` by slug (with `_` → `-` normalization), falling back to case-insensitive name match. 400 with helpful detail on miss. - `priority` (string) — maps mobile labels `critical/high/medium/low` to `P1/P2/P3/P4`, then looks up `TicketPriority` by code. Also accepts raw P-codes and priority names for backends that already use those. Server-only fix; the v3.17.446 AAB on Play Console starts working as soon as Apply lands this on prod. 3 new tests in `MobileTicketsTests`: PATCH by slug succeeds, unknown slug → 400, priority label `'critical'` → P1. ## [3.17.449] - 2026-05-09 ### Mobile vault endpoints (closes the 404 on the vault tab) The mobile app's vault tab returned 404 because `/api/mobile/v1/vault/` and friends never landed despite a roadmap annotation suggesting they had. Built the missing surface: - `views_vault.vault_list_view` — `GET /vault/?search=&organization_id=&page=` paginated, org-scoped via `accessible_org_ids`. Search matches title / username / url / notes. Ordered by org then title so the upcoming mobile org-grouping work has a sensible default. Never returns secrets. - `views_vault.vault_detail_view` — `GET /vault//`. 404 on cross-org reads (no existence leak). - `views_vault.vault_reveal_view` — `POST /vault//reveal/`. Mirrors the web vault's security guarantees: - 30/hour per-user throttle via new `MobileVaultRevealRateThrottle` (scope `vault_reveal` added to `REST_FRAMEWORK.DEFAULT_THROTTLE_RATES`). - Per-credential approval gate honored. If `requires_reveal_approval` is set with no current approval, returns 202 + `request_url` so the mobile UI can deep-link to the web approval flow rather than silently failing. - `vault.access_rules.evaluate` (GeoIP / IP / time-of-day) honored. 403 with reason if denied. - Decrypts via existing `Password.get_password()` → `decrypt_password()` with AAD verification. - Marks the satisfying approval as used so the next reveal needs a fresh request. - Audit log entries on every read attempt and decision (allow / deny / decrypt-failed) tagged `channel='mobile'`. 7 tests in `MobileVaultEndpointTests` cover org scoping (other-org entries are 404), no-secret-in-list/detail, plaintext returned on reveal, search filtering, and unauthenticated rejection. Server-only change. Mobile already calls these paths; the v3.17.446 AAB on Play Console will start working once Apply lands this on prod — no rebuild needed. ## [3.17.448] - 2026-05-09 ### Mobile dashboard crash fix — server returns the shape the client expects After completing the MFA challenge the React Native dashboard threw "undefined is not a function" inside `` because `data.recent_assets.map(...)` was being called on an integer. **Server/client contract was misaligned since v3.17.347:** - `mobile/src/types/api.ts::DashboardSummary` declared `recent_tickets: Ticket[]`, `recent_assets: Asset[]`, `security: SecuritySummary`, plus counts `monitors_down` and `my_open_tickets`. - `api_mobile/views_dashboard.py::dashboard_view` returned counts where arrays were expected, used `offline_monitors` instead of `monitors_down`, and never returned `my_open_tickets`, `recent_tickets`, or a `security` object at all. The mismatch only surfaced now because previous releases couldn't get past login. **Fix:** - Rewrote `dashboard_view` to return the shape the type defines: - Counts: `open_tickets`, `critical_tickets`, `my_open_tickets`, `expiring_soon`, `monitors_down` - `recent_tickets`: top 5 non-terminal tickets ordered by `-updated_at`, serialized via the existing `views_tickets._serialize_ticket` - `recent_assets`: top 5 most-recently-created assets, serialized via `views_assets._serialize_asset` - `security`: `{open_alert_count, critical_alert_count, high_alert_count, medium_alert_count, low_alert_count, recent_alerts: [...]}` - Each section is wrapped in try/except so a missing optional app (`psa`, `security_alerts`, …) leaves its slice empty rather than 500ing the whole dashboard. - 3 new tests in `api_mobile.tests.MobileDashboardShapeTests` lock the contract: 200 + arrays present + auth required + arrays default to `[]` not `None`. **No mobile rebuild needed** — fix is server-side, ships via Apply. ## [3.17.447] - 2026-05-09 ### Public privacy policy + Play Console submission docs Play Console requires a public privacy-policy URL and a completed Data Safety questionnaire before any track (including Internal testing) accepts a release for review. Both shipped here. **New public route:** - `core.views.privacy_policy` — anonymous-accessible view at `GET /privacy-policy/` (mounted at the root in `config/urls.py`, not under `/core/`). Renders `docs/PRIVACY_POLICY.md` server-side via the `markdown` package; same single-source-of-truth pattern as `/core/roadmap/`. Standalone HTML template (no auth chrome) so Play Console reviewers see a clean page. - 4 tests in `core/tests/test_privacy_policy.py` covering anonymous-200, named-URL reverse, markdown→HTML, and `Content-Type: text/html`. **New docs (source of truth):** - `docs/PRIVACY_POLICY.md` — what data the app sends, what it stores, what it doesn't collect. Calibrated to the v3.17.446 AAB's actual permissions (no location, no camera, no contacts) and noted that a future GPS timeclock will revise. - `docs/PLAY_DATA_SAFETY.md` — pre-filled answers for every question in Play Console's Data Safety form, broken down by section and data type, with the exact wording for the deletion-request free-text field. - `docs/PLAY_STORE_LISTING.md` — short description, full description, "what's new" copy, app-content rating answers, and the App-access reviewer-login text. ## [3.17.446] - 2026-05-09 ### Mobile login fix + signed-AAB build unblock Two unrelated mobile blockers shipped together so internal-testing testers can actually log in. **Mobile login was returning "username and password are required" with credentials clearly entered:** - `mobile/src/api/auth.ts` — login `POST /auth/login/` body now sends `{username, password}` instead of `{email, password}`. Backend (`api_mobile/views_auth.py:86`) reads `request.data.get('username')`, so the previous body left `username` empty server-side. The login screen field accepts either email or username (Django `authenticate()` handles both via the email-or-username backend), so no UI change is needed. **Signed AAB build was failing on `expo-modules-core:compileReleaseKotlin`:** - `mobile/patches/expo-modules-core+1.12.26.patch` — adds `?.` null-safe call to `PermissionsService.kt:166`. Android SDK 35 made `PackageInfo.requestedPermissions` nullable; `expo-modules-core@1.12.26` (Expo SDK 51) was written for SDK 34 and accessed it directly. Newer Kotlin compiler rejects this with `Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver`. Cannot downgrade compileSdk because Play Console requires `targetSdk 35` (and `compileSdk` must be ≥ `targetSdk`). - `mobile/package.json` — adds `patch-package` devDep + `postinstall` script so the patch survives `npm install`. **Version bump:** - `config/version.py` — 3.17.445 → 3.17.446. - `mobile/app.json` — Android `versionCode` 3170445 → 3170446 so Play Console accepts the new internal-testing AAB (it rejects duplicate versionCodes). ## [3.17.445] - 2026-05-08 ### Documentation sweep, mobile-app trim, Play Console targetSdk 35 Three threads land together: **Documentation:** - `FEATURES.md` — full Compliance Frameworks section (Phase 41) + Native Mobile Apps section (Phase 8). Header bumped to v3.17.444. - `README.md` — version badge bumped to v3.17.444; "Latest Release" rewritten with the actual recent phases (Compliance, Mobile, Evidence Packs, Onboarding/Offboarding) — was stuck on v3.17.143. Compliance + Mobile screenshot rows added to the gallery and the index list. - `docs/SCREENSHOT_CHECKLIST.md` — Compliance + Mobile sections added at the top. - `user-guide/compliance.md` — new page covering enroll → attest → recertify → PDF flow + data model. Linked from `user-guide/README.md`. - `scripts/generate_screenshots_v2.py` — captures `compliance-org-dashboard` + `compliance-checklist` automatically when an org is enrolled. - `docs/screenshots/compliance-org-dashboard.png`, `docs/screenshots/compliance-checklist.png`, `docs/screenshots/compliance-checklist-annotated.png` — fresh captures from the live app, with numbered callouts on the annotated version. **Mobile app — six-area top nav (per user directive):** - `mobile/app/dashboard.tsx` — replaces the previous Settings header button with a primary 5-tile nav row (Assets / Vault / Docs / PSA / Operations). Monitoring / Security / Timeclock tile destinations rerouted through `/operations`. - `mobile/app/operations/index.tsx` — new hub screen consolidating Timeclock, Monitoring, Security alerts, and Settings under one route. - `mobile/app/_layout.tsx` — top-level Stack screens reorganized: 6 primary (Dashboard / Assets / Vault / Docs / PSA / Operations) above the secondary screens reachable via deep-link. **Play Console fixes (target SDK 35 + R8 mapping):** - `mobile/app.json` — adds `expo-build-properties` plugin with `compileSdkVersion: 35`, `targetSdkVersion: 35`, `buildToolsVersion: "35.0.0"`, `enableProguardInReleaseBuilds: true`, `enableShrinkResourcesInReleaseBuilds: true`. Resolves Play Console error "must target at least API level 35". - `mobile/package.json` — adds `expo-build-properties: ~0.12.5` dependency. - `local_apps/play_publish/scripts/build-aab.sh` — PATH bumped to `build-tools/35.0.0`; captures `mapping.txt` from `build/outputs/mapping/release/` next to the AAB so it can ship with the upload. - `local_apps/play_publish/scripts/upload-aab.py` — after the AAB upload, also calls `androidpublisher.deobfuscationfiles.upload(deobfuscationFileType='proguard', …)` to ship `mapping.txt`. Resolves the "no deobfuscation file associated" warning. - Android SDK platform `android-35` + build-tools `35.0.0` installed locally via `sdkmanager`. ## [3.17.444] - 2026-05-08 ### Phase 41 — Compliance Frameworks & Recertification: shipped Phase 41 is now fully shipped. Roadmap header advances from `[in progress]` → `[shipped — v3.17.444]`. Sizing-table row added: `41 — Compliance Frameworks & Recertification | M | shipped v3.17.435–444 | extends accounts + audit + reports.pdf_export`. What landed across the train (10 releases): | Release | Slice | | --- | --- | | v3.17.435 | Phase 41 roadmap entry + app stub | | v3.17.436 | Models: ComplianceFramework, Category, CheckItem, OrganizationCompliance, OrganizationComplianceItem, RecertificationReminder + migration + admin | | v3.17.437 | `seed_pci_dss` mgmt cmd → 1 framework, 12 categories (PCI Requirements 1-12), 38 check items keyed to real PCI-DSS v4.0 control numbers | | v3.17.438 | `seed_hipaa` mgmt cmd → 1 framework, 3 categories (Administrative + Physical + Technical Safeguards), 33 check items keyed to real CFR refs | | v3.17.439 | Per-org dashboard `/compliance/organizations//` + Enroll button + status pills + progress bar per framework | | v3.17.440 | Checklist UI `/compliance/...//` with per-row attestation save + audit logging on status change | | v3.17.441 | Customer-facing PDF report (KPI grid + per-category table) using Phase 19's `reports.pdf_export.render_pdf` | | v3.17.442 | `send_compliance_recertifications` cron (idempotent + 7-day dedup) + `RecertificationReminder` audit row | | v3.17.443 | Settings card on checklist (toggle / interval / notify_email) + "Mark recertified now" button | | v3.17.444 | Phase close (this release) | Total: 36+ tests across 7 test classes in `compliance/tests.py`, all passing. ### Operator quick-start ```bash # Seed the framework catalog (idempotent — safe to re-run) python manage.py seed_pci_dss python manage.py seed_hipaa # Add daily recertification cron (crontab -e) 0 9 * * * cd /home/administrator && /home/administrator/venv/bin/python manage.py send_compliance_recertifications ``` Then for each client org needing compliance: `/compliance/organizations//` → **Enroll** the framework → walk the checklist → **Mark recertified now** when done. The cron handles future reminders. ## [3.17.443] - 2026-05-08 ### Added — Phase 41 v7: recertification toggle UI + Mark Recertified button On the checklist page (`/compliance/organizations///`), a new "Recertification" card sits below the progress bar with two halves: **Left side — settings form:** - Toggle switch: enable/disable email reminders (`recertification_emails_enabled`) - Interval dropdown: Monthly (30) / Bi-monthly (60) / Quarterly (90) / Semi-annual (180) / Annual (365). Validated server-side against `VALID_INTERVAL_DAYS`; invalid values silently fall back to current. - Notify email override (optional). If blank, the recertification cron resolves to the org's primary admin. - **Save settings** button POSTs to `/compliance/.../settings/`. **Right side — manual recertify:** - Shows last-recertified timestamp + days until next reminder + the actual due date. - **Mark recertified now** button (green) → POSTs to `/compliance/.../recertify/` → stamps `last_recertified_at = now()`. Confirms via `confirm()` dialog before submit. Both actions write `AuditLog` entries (action=update). Description on settings save lists exactly what fields changed (e.g. `Recert settings: emails_enabled: True -> False; interval_days: 365 -> 30`). ### Tests 6 new: settings save updates fields; invalid interval falls back; unchecked toggle disables; mark-recertified stamps timestamp + audits; outsider blocked. ## [3.17.442] - 2026-05-08 ### Added — Phase 41 v6: recertification reminder cron New management command `python manage.py send_compliance_recertifications` (run daily via cron) walks every `OrganizationCompliance` with `recertification_emails_enabled=True`, computes `recertification_due_at`, and sends a reminder email if: 1. now() ≥ due_at 2. No `RecertificationReminder` row for this enrollment exists in the last 7 days (dedup) Recipient resolution order: 1. `OrganizationCompliance.notify_email` (if explicitly set) 2. First active `Membership(role in ['owner','admin'])` user's email 3. Fallback: `DEFAULT_FROM_EMAIL` (with a warning logged) Email body links to the checklist (uses `SITE_URL` if defined). Subject indicates whether the recertification is due today, in N days, or overdue by N days. `--dry-run` flag identifies which enrollments would be reminded without sending or recording. ### Cron setup (operator) Add to crontab — daily at 09:00 UTC works: ``` 0 9 * * * cd /home/administrator && /home/administrator/venv/bin/python manage.py send_compliance_recertifications ``` ### Tests 5 new: due enrollment gets an email + audit row; dedup-within-7-days; disabled-flag respected; not-yet-due skipped; dry-run sends nothing + writes no rows. ## [3.17.441] - 2026-05-08 ### Added — Phase 41 v5: customer-facing PDF compliance report The dashboard's **Report (PDF)** button + the checklist's **Download report (PDF)** button now produce a real PDF instead of a placeholder. New view at `/compliance/organizations///report.pdf`. Layout (using the Phase 19 `reports.pdf_export.render_pdf` helper): - **Title**: ` Compliance Report` - **Subtitle**: org name + framework version + generated timestamp + last-recertified date - **KPI cards** (4-column grid): Compliance %, Compliant count, Partial count, Non-compliant count, N/A count, Unanswered count, Total controls, Days until recertification - **One table per category** with columns: Control / Status / Evidence (URL) / Notes (truncated to 280 chars per cell) Generates an `AuditLog` entry on every PDF download (action=view, description="Generated PDF compliance report for ") so the audit trail captures who pulled the report. Filename pattern: `--.pdf`. ### Tests 3 new tests: PDF response returns 200 + correct Content-Type + valid `%PDF` magic header + body length sanity check; audit log row written; outsider users blocked (404). ## [3.17.440] - 2026-05-08 ### Added — Phase 41 v4: checklist UI + per-row attestation save The dashboard's "Open checklist" button now lands on a working page. New view at `/compliance/organizations///` renders the full checklist grouped by category, with each item showing: - Title + description (verbatim control text from the seed) - Evidence hint (yellow lightbulb) - Status dropdown (compliant / partial / non-compliant / N/A / unanswered) - Notes textarea - Evidence link URL field - "Last reviewed" timestamp + reviewer username Items are color-coded by left border: green compliant, orange partial, red non-compliant, grey N/A, light-grey unanswered. Per-row save POSTs to `/compliance/organizations///save/`. Server validates the status against the model's `STATUS_CHOICES` (invalid input falls back to `unanswered`), updates `last_reviewed_at` + `last_reviewed_by`, and writes an `AuditLog` entry on every status change with description `"Status: -> "`. Redirect lands the user back on the same item via fragment anchor (`#item-`) so they don't lose place after save. Header card shows live progress (`percent_compliant` + status counts). ### Fixed — view tests using `c.login()` triggered django-axes Earlier compliance tests called `c.login(username, password)` which goes through django-axes's authentication backend; that backend requires a `request` object and raises `AxesBackendRequestParameterRequired` from the test client. Switched all 8 calls to `c.force_login(user)` which bypasses the auth backend chain (test-only — production unaffected). ### Tests 4 new checklist tests + 8 existing rewritten to use `force_login`. **Ran 28, OK.** ### Side fixes (orthogonal to Phase 41) - AAB delete capability: red **Delete** button next to each row in the play_publish dashboard's Built AABs table; new `/play_publish/delete/` POST view with path-traversal guard. - Build progress panel auto-clears on success (was leaving stale "complete" state on the page); upload progress panel auto-hides 5s after success. Both via dashboard JS only — local-only. - `mobile/app.json` package + bundleIdentifier changed from `com.clientstor.mobile` → `com.clientstor.mspreboot` to match the Play Console listing the user created. ## [3.17.439] - 2026-05-08 ### Added — Phase 41 v3: per-org compliance dashboard New view at `/compliance/organizations//` lists every active framework with the org's enrollment status. For unenrolled frameworks, an **Enroll** button creates the OrganizationCompliance row + bulk-creates an OrganizationComplianceItem (status=`unanswered`) for every check item in the framework — so the operator can immediately walk the checklist. For enrolled frameworks, the card shows: - **Compliance progress bar** (`percent_compliant`) - Status counts (compliant / partial / non-compliant / N-A / unanswered) - **Recertification due** with days remaining (red text if overdue) - **Open checklist** + **Report (PDF)** buttons (real implementations land in v3.17.440 + v3.17.441; URLs stubbed in this release so the dashboard renders) ### Audit Enrollment writes an `AuditLog` entry with action `create`, object_type `compliance.OrganizationCompliance`, and a description naming the framework + version. ### Tests 4 new view tests in `compliance/tests.py`: - Dashboard renders + lists frameworks - Enroll creates the right number of attestation items (38 for PCI-DSS) - Enrollment is idempotent (re-POST is no-op) - Outsider users get 404 on cross-org dashboard access All compliance tests pass. ## [3.17.438] - 2026-05-08 ### Added — Phase 41: HIPAA Security Rule seed Management command `python manage.py seed_hipaa` populates the HIPAA Security Rule framework with all three safeguard categories from 45 CFR Part 164, Subpart C: 1. **Administrative Safeguards (164.308)** — 13 items including Security Management Process, Risk Analysis, Risk Management, Sanction Policy, Information System Activity Review, Assigned Security Responsibility, Workforce Security, Information Access Management, Security Awareness Training, Security Incident Procedures, Contingency Plan, Evaluation, Business Associate Contracts. 2. **Physical Safeguards (164.310)** — 8 items: Facility Access Controls, Contingency Operations, Facility Security Plan, Workstation Use, Workstation Security, Device and Media Controls, Disposal, Media Re-use. 3. **Technical Safeguards (164.312)** — 12 items: Access Control, Unique User Identification, Emergency Access Procedure, Automatic Logoff, Encryption and Decryption, Audit Controls, Integrity, Mechanism to Authenticate ePHI, Person or Entity Authentication, Transmission Security, Integrity Controls, Encryption. Each item references the actual CFR subsection (e.g. `164.308(a)(1)(ii)(A)` for Risk Analysis) with a verbatim-style description and `evidence_hint` of what auditors typically expect. Idempotent — re-runs do `update_or_create` keyed on `(framework, slug)` pairs. ### Tests 2 new tests: HIPAA seed creates 3 categories + 25+ items; idempotent on re-run. ## [3.17.437] - 2026-05-08 ### Added — Phase 41: PCI-DSS v4.0 seed Management command `python manage.py seed_pci_dss` populates the PCI-DSS v4.0 framework with all 12 Requirements as categories and ~35 representative check items. Each item references a real PCI-DSS v4.0 control number (e.g. `1.2.1`, `8.4.2`, `11.3.2`) and includes verbatim-style description + an `evidence_hint` for what an auditor typically expects. Categories (= PCI-DSS v4.0 Requirements): 1. Install and maintain network security controls 2. Apply secure configurations to all system components 3. Protect stored account data 4. Protect cardholder data with strong cryptography during transmission 5. Protect all systems and networks from malicious software 6. Develop and maintain secure systems and software 7. Restrict access to system components and CHD by need-to-know 8. Identify users and authenticate access to system components 9. Restrict physical access to cardholder data 10. Log and monitor all access 11. Test security of systems and networks regularly 12. Support information security with organizational policies and programs Idempotent — re-running updates by `(framework, category)` slug pair, never duplicates. An MSP can extend each category in the management command source over time as their attestation needs grow. ### Tests 2 new tests: seed populates framework + 12 categories + 30+ items; seed is idempotent across repeated runs. ## [3.17.436] - 2026-05-08 ### Added — Phase 41: compliance framework + per-org attestation models First release of the new Phase 41 (Compliance Frameworks & Recertification). Adds the data model the rest of the train builds on. Existing Phase 39 evidence-pack flow is untouched. - `ComplianceFramework` — system-defined framework (PCI-DSS, HIPAA, etc.). Pre-seed via mgmt cmds in v3.17.437/438. - `ComplianceCategory` — group of controls within a framework (PCI Requirement 1, HIPAA Administrative Safeguards, etc.). FK framework + slug + order. - `ComplianceCheckItem` — individual control. FK category + slug + name + description + evidence_hint + order. - `OrganizationCompliance` — per-org enrollment in a framework. Tracks `recertification_interval_days` (default 365), `recertification_emails_enabled`, `last_recertified_at`, `notify_email`. `recertification_due_at` + `percent_compliant()` helpers. - `OrganizationComplianceItem` — per-org attestation for a single control. Status (compliant / partial / non_compliant / not_applicable / unanswered) + notes + evidence URL + last_reviewed_at/by. - `RecertificationReminder` — audit row recording sent reminder emails. ### Migration `compliance/0001_initial.py` adds all five tables. Backwards-compatible (existing evidence-pack functionality unaffected). ### Roadmap - New phase header `## Phase 41 — Compliance Frameworks & Recertification **(M)** [in progress]` inserted before the `---` divider that precedes Phase 8. ### Tests 6 new model tests in `compliance/tests.py`: framework create + str, category/item chain, org enrollment + status_counts(), percent_compliant(), recertification_due_at, unique constraint per (org, framework). ## [3.17.435] - 2026-05-08 ### Fixed — Play Console rejected uploads as duplicate versionCode Expo's default Android `versionCode` is `1`. Once Play Console accepts a versionCode for an app, it can't be reused — the next upload gets a cryptic `"This release does not add or remove any app bundles"` and `"You can't rollout this release because it doesn't allow any existing users to upgrade to the newly added app bundles"`. Fix in `mobile/app.json` + `local_apps/play_publish/scripts/build-aab.sh` (local-only): pin `android.versionCode = 3170435` and have the build script auto-derive the value from `config/version.py` on every build using `major × 1,000,000 + minor × 10,000 + patch`. Each release-version-bump now yields a unique higher versionCode automatically. Max Android versionCode is 2.1 billion — plenty of headroom. ### Chore — version bump triggers gunicorn restart The play_publish app's view-layer changes from earlier (Python uploader, expanded GCP walkthrough, no-cache headers) need a gunicorn restart to load. Apply v3.17.435 from Settings → Updates picks them up. ## [3.17.434] - 2026-05-08 ### Chore — version bump for gunicorn restart No code change in this commit. Used as the Apply trigger to graceful-restart gunicorn so it picks up local-only Django app changes outside the public repo. The build_aab + upload_aab Python wrappers were updated locally; this version bump exposes a fresh "Update available" so the operator can click **Apply** and pull the new code into running workers. ## [3.17.433] - 2026-05-08 ### Fixed — `bundleRelease` failed with `Unable to resolve module crypto from axios` The signed-AAB build (`./gradlew bundleRelease`) was failing at `:app:createBundleReleaseJsAndAssets`. Root cause: Metro (the React Native bundler) was resolving axios via its `dist/node/axios.cjs` entry, which imports Node built-ins (`crypto`, `url`, `http`) that don't exist in the React Native runtime. The debug build worked because `expo prebuild` for debug uses a different code path that picks axios's RN-friendly entry by default. Release builds went through Metro's full resolver and picked the wrong export. Fix: new `mobile/metro.config.js` sets `resolver.unstable_conditionNames = ['require', 'react-native', 'browser']` so Metro consults the `react-native` / `browser` conditional exports in package.json before falling back to `node`. axios + any similar dual-build dep now resolves correctly under release. ### Tests None — Metro resolver config; verified by reading the v3.17.432 build log error at `:app:createBundleReleaseJsAndAssets` and the axios package.json `exports` map. ## [3.17.432] - 2026-05-08 ### Added — Generic local-app loader (`local_apps/`) A new auto-discovery hook lets you drop a Django app at `/local_apps//` and have it loaded on the next gunicorn restart. Used for environment-specific extensions that don't belong in the public repo (custom auth backends, internal-only dashboards, etc.). The loader is **generic** — it does not name any specific app, and it's fully backwards-compatible (no `local_apps/` directory means no behavior change). Implementation (~25 lines total): - `config/settings.py` — appends a small block at the end that walks `/local_apps/`, prepends it to `sys.path`, and adds any subdirectory containing `apps.py` to `INSTALLED_APPS`. Skips entries beginning with `_` or `.`. - `config/urls.py` — appends a similar block that mounts each subdirectory's `urls.py` at `//` if present. Whatever lives under `local_apps/` is intentionally NOT tracked. The directory is gitignored via `.git/info/exclude` (per-clone, not in the repo's tracked `.gitignore`) so a fresh clone has no traces. ### Tests None — generic plugin scaffolding; no behavior change for fresh clones. ## [3.17.431] - 2026-05-08 ### Added — Live build progress bar (real percentage, not just animated stripes) The Building page used to refresh the entire HTML every 5 seconds via meta-refresh. The progress bar was just a CSS striped animation — it didn't reflect actual progress, so a long step like `:app:minifyDebugWithR8` (which can run 10+ minutes silently on R8) made the page look frozen even when work was happening. New API endpoint `GET /core/mobile-apps/build-progress//` (in `core/views.py::mobile_app_build_progress`) returns: - `status` — building / complete / failed - `tasks_seen` — count of `> Task :` lines in `_build.log` - `tasks_total_est` — empirical baseline of 588 (the v3.17.427 successful build's task count); auto-bumps if a build exceeds it - `percent` — `tasks_seen / tasks_total_est`, capped at 99% until status==complete - `current_task` — the most recent `> Task :` line (e.g. `> Task :app:minifyDebugWithR8`) - `elapsed_s` — seconds since `status_data['timestamp']` - `log_tail` — last 30 non-blank log lines The Building page (in `download_mobile_app`) replaces the meta-refresh + striped CSS bar with: - A real Bootstrap-style green progress bar that smoothly transitions (`transition: width 0.5s ease`) from 0%→99% as Gradle runs through its task list - Live "**42%** · 247 / ~588 tasks" counter - "Current task: `> Task :app:minifyDebugWithR8`" callout so you can see what's happening RIGHT NOW - Live elapsed timer (1s tick) - Live log tail (last 30 lines, auto-scrolled) - Auto-redirect to download URL when status flips to `complete` - Auto-page-reload (which lands on the failed-status page from v3.17.429) when status flips to `failed` Polls every 1.5s. The endpoint is auth-gated (`@user_passes_test(is_staff or is_superuser)`). ### Tests None — frontend polling + JSON endpoint; verified by tracing the count logic and the `> Task :` regex. ## [3.17.430] - 2026-05-08 ### Fixed — APK build failed on `getDefaultProguardFile()` v3.17.428 appended the minify+shrink patch to `android/app/build.gradle` as bare property setters: ```gradle android.buildTypes.debug.proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ``` That fails with `Could not find method getDefaultProguardFile() for arguments [...] on project ':app'` because outside an `android { }` block the receiver of `getDefaultProguardFile` is the bare `Project`, which doesn't have that method. The method belongs to the `AndroidExtension` (the object the `android { }` block configures). Fix: wrap the patch in a proper nested `android { buildTypes { debug { ... } } }` block so Gradle's resolver hits the right receiver: ```gradle // CST-DEBUG-MINIFY android { buildTypes { debug { minifyEnabled true shrinkResources true proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" } } } ``` The block form is also the canonical idiom in the Android Gradle Plugin docs. ### Tests None — Gradle config syntax fix; verified against AGP docs for `getDefaultProguardFile()` scope. ## [3.17.429] - 2026-05-08 ### Fixed — Mobile Apps page: single-button rebuild + visible build errors Two genuine UX bugs that the previous releases didn't catch: 1. **Two buttons, two flows for "rebuild"** — the admin page had a "Rebuild from latest code" button that POSTed to wipe the cache, AND a separate "Build & download APK" link that triggered the actual build. User had to click two things in sequence. Now a single `?rebuild=1` query param on the existing download URL handles both wipe + kickoff atomically. The admin page renders one button per state: **Download APK** + **Rebuild from latest code** when an APK exists; **Build APK from latest code** when not. 2. **Build errors disappeared on auto-refresh.** When a build failed, `download_mobile_app`'s failed-status branch deleted the status file and rendered a generic "Android App Not Created Yet" page — discarding the error message AND the build log. The user couldn't see why the build had failed because the failure UI was the same as the no-build-yet UI. Now the failed branch: - Keeps the status file - Reads the last 80 non-blank lines of `android_build.log` - Renders a red "❌ Android Build Failed" card with the error message + scrollable log + a single **🔁 Retry build** button (which clicks through to `?retry=1` to wipe the failure and start fresh) - **Does NOT auto-refresh**, so the error stays put until the user explicitly retries or navigates away ### Files - `core/views.py::download_mobile_app` — `?rebuild=1` short-circuit; new failed-status page with embedded log - `templates/core/mobile_apps_admin.html` — consolidated to one primary button per state, links to `?rebuild=1` ### Tests None — UI consolidation; verified by tracing the `?rebuild=1` short-circuit and the failed-status template render path. ## [3.17.428] - 2026-05-08 ### Smaller — APK 49MB → ~20-25MB (R8 minify + resource shrink on debug) Default Expo debug builds skip R8 minification and resource shrinking; that's why the v3.17.425 APK was still 49MB even with arm64-v8a-only native libs. Patching `android/app/build.gradle` after `expo prebuild` to force `minifyEnabled = true` and `shrinkResources = true` on the debug variant runs R8 over the JS bundle + Java/Kotlin classes and drops unused resources. In `core/management/commands/build_mobile_app.py`, the existing build-gradle patcher now appends a second block (guarded by `// CST-DEBUG-MINIFY` marker for idempotency): ```gradle android.buildTypes.debug.minifyEnabled = true android.buildTypes.debug.shrinkResources = true android.buildTypes.debug.proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ``` This uses the standard Android optimize rules + the Expo/RN-shipped `proguard-rules.pro` (already correct for keep rules on Hermes / Reanimated / native modules). No release keystore needed — debug keystore continues signing the APK. Expected APK size: **~20-28MB** (down from 49MB). The new app has more deps than the Feb 2026 skeleton (Expo Router + TanStack Query + Reanimated + Gesture Handler + Screens + others), so it won't quite hit the original 20MB but should land close. To get the smaller APK: Apply v3.17.428 → Mobile Apps → **Rebuild from latest code** → **Build & download**. ### Tests None — Gradle config injection; verified by reading the build.gradle patch + the standard Android proguard-android-optimize.txt rules. ## [3.17.427] - 2026-05-08 ### Fixed — APK build looked hung even when running fine Three cosmetic bugs in `download_mobile_app`'s "Building..." page combined to make the build look hung even though it always finished in 1-2 min: 1. **Log filter stripped Gradle output.** The filter only kept lines containing `===`, `Step`, `Building`, `Installing`, `> npx`, `> ./gradlew`, `Error:`, `failed`, or `complete`. Gradle's actual progress (`> Task :react-native-screens:assembleDebug`, etc.) didn't match any of those, so the visible log froze at the last marker line for the whole build. Widened the filter to keep almost everything except `npm warn` / `warning:` noise. Now keeps the last **60 lines** instead of 20. 2. **"Elapsed Time: Calculating…" never updated.** The JS that ticks the elapsed counter only existed on the "starting a fresh build" page, not on the "build in progress" page (the one users actually see during a build). Added the same `setInterval(tickElapsed, 1000)` to the in-progress page, sourced from `status_data['timestamp']`. 3. **"Showing last 100 lines"** label was a lie (the filter only kept 20). Updated to "Showing last 60 lines of build output" and added a link to `/core/mobile-apps/` so users can navigate back to the admin landing page if they want the explicit Download button instead of waiting for the auto-refresh + auto-download. The `mobile_apps_admin` page at `/core/mobile-apps/` already detects the APK on disk correctly (`os.path.exists(binary_path)`) — if you reach that URL in a fresh tab right now, it shows the green "Ready" badge with the Download APK button. The "stuck" feeling came from the building page's stale snapshot, not from the admin page. ### Tests None — UI cosmetic fixes; verified by reading the build log filter logic and the JS update path. ## [3.17.426] - 2026-05-08 ### Fixed (third try) — 502s on `/static/...` after Apply reload v3.17.422's parallel warmup probed `/api/update-progress/` six times — a Django dynamic endpoint. But static files (`/static/css/themes.*.css`, `/static/manifest.*.json`, etc.) go through **WhiteNoise** on the same gunicorn workers. Workers can be healthy on dynamic requests while WhiteNoise's static path is still flaky during a worker cycle, so the warmup said "all good" → reload fired → static fan-out hit a worker mid-rotation → 502s on CSS/JS/favicon/manifest. Fix in `templates/core/system_updates.html::warmupAndReload()`: scrape `` URLs starting with `/static/` from the current page (CSS, manifest, favicon) and probe THOSE in parallel with the API URL. The set is capped at 8 URLs to avoid swamping a small worker pool. If any return non-200 we drop back to single-probe polling. Once all 8 return 200 simultaneously we know the exact set of URLs the reload will refetch is healthy on every worker. The progress message now reads `"All paths responding (8 URLs) — reloading in 3 seconds…"` so you can see it's actually exercising the static path. ### Tests None — frontend warmup probe path change; verified by reading the page's `` tags + WhiteNoise middleware position in `config/settings.py`. ## [3.17.425] - 2026-05-08 ### Fixed (for real this time) — APK was still 137MB after v3.17.424 The `-PreactNativeArchitectures=arm64-v8a` Gradle property in v3.17.424 did nothing on `assembleDebug`. That property is only consumed by the `splits { abi { include (*reactNativeArchitectures()) } }` block, which is gated behind `enableSeparateBuildPerCPUArchitecture` — disabled by default for debug builds. Inspecting the v3.17.424 APK confirmed all 4 ABIs were still bundled (`lib/arm64-v8a/`, `lib/armeabi-v7a/`, `lib/x86/`, `lib/x86_64/` — ~150MB total of native libs). Real fix in `core/management/commands/build_mobile_app.py`: after `expo prebuild --clean` regenerates `android/app/build.gradle`, append `android.defaultConfig.ndk.abiFilters 'arm64-v8a'` to the file. That sets the NDK ABI filter directly on the default variant — the build only packages arm64-v8a native libs, and the other 3 ABI directories never enter the APK. Patch is idempotent (guarded by a `// CST-ABI-FILTER` marker comment) so re-running the build after a clean prebuild always re-injects the line. Expected APK after v3.17.425: **~40-50MB**. If size doesn't drop on the next rebuild, unzip the APK and check `lib/` — only `lib/arm64-v8a/` should be present. ### Tests None — Gradle gradle-file injection; verified by inspecting the v3.17.424 APK contents (all 4 ABIs present, total native libs 147.9MB) and the React Native default-config docs. ## [3.17.424] - 2026-05-08 ### Fixed — APK was 151MB, now ~40MB The v3.17.419 switch to `assembleDebug` shipped 4 native-library architectures by default (`armeabi-v7a + arm64-v8a + x86 + x86_64`) so the build would also work on Android emulators. Each ABI is ~30-40MB of compiled native libs (React Native runtime, Reanimated, Hermes, etc.) — total APK ballooned to 151MB. For sideload to internal field techs, only `arm64-v8a` matters — every Android phone shipped since 2017 uses arm64. Adding `-PreactNativeArchitectures=arm64-v8a` to the Gradle invocation in `core/management/commands/build_mobile_app.py` strips the other three ABIs out of the APK. Expected APK size: **~40-50MB** (down from 151MB). Build time also drops by ~1 minute since there are 75% fewer native libs to compile. If we ever need x86 support (Android emulators, ChromeOS, some Lenovo tablets), bump the property to `arm64-v8a,x86_64` and rebuild. ### Tests None — Gradle config switch; verified by reading the React Native build doc + the current APK output size. ## [3.17.423] - 2026-05-08 ### Fixed — APK build died on gunicorn restart, status stuck at "Building" The Android APK build was launched as a daemon thread that ran `subprocess.run([..., 'build_mobile_app', 'android'])`. The subprocess inherited the gunicorn worker's process group — so when a subsequent `systemctl reload huduglue-gunicorn.service` (e.g. from applying a Django update) sent SIGTERM through the worker's process tree, the gradle build was killed mid-flight. The build log + status file froze and the UI looped forever showing "Building debug APK with Gradle (5-10 minutes)…". Two fixes in `core/views.py::download_mobile_app`: - **Detached subprocess**: switched `subprocess.run(... capture_output=True)` to `subprocess.Popen(..., start_new_session=True, close_fds=True)` with stdin/stdout/stderr redirected to `DEVNULL`. `start_new_session=True` calls `setsid()` on the child so it becomes its own session leader, fully decoupled from gunicorn's process group. Future gunicorn restarts will not kill running APK builds. - **Stale-status detection**: when the page is hit while a build is "in progress", the view now checks the mtime of both `_build_status.json` and `_build.log`. If neither has been touched in 5+ minutes, the build process is presumed dead and the status is flipped to `failed` with a friendly message ("Build process appears to have died… most likely cause: gunicorn was restarted while the build was running. Click Retry to start a fresh build."). The existing failed-branch UI then offers a Retry button. ### Tests None — process-group + filesystem timing fix; verified by tracing the original death (no gradle/cmake/java processes after gunicorn reload) and confirming `start_new_session=True` documented behavior. ## [3.17.422] - 2026-05-08 ### Fixed — 502s on static files after Apply reload After v3.17.421 successfully completed an update, the page reload that follows could fan out CSS/JS/font/manifest requests in parallel and catch a gunicorn worker still in the middle of its post-SIGHUP graceful-restart cycle — that worker 502s. The user saw a broken page with `ERR_ABORTED 502` for `themes.*.css`, `manifest.*.json`, etc. even though gunicorn was up overall. Root cause: the public edge OpenResty proxies straight to `gunicorn:8000` (no local nginx in the path serving static directly). When systemctl reload replaces workers one at a time, individual workers go through a brief unavailable window. Two consecutive 200 probes from a single connection wasn't enough — page reload uses many parallel connections and any of them can hit a worker in the middle of replacement. Fix in `templates/core/system_updates.html::waitForServerThenReload()`: 1. Bumped `consecutiveOk` requirement from 2 → **3** (faster cadence, 400ms between probes). 2. After 3 consecutive OKs, run a **`warmupAndReload()`** phase: fire **6 parallel probes** in flight at once. If ANY one fails, drop back to single-probe polling. This actively exercises multiple workers in parallel, surfacing any laggard before the page reload does. 3. After all 6 warmup probes succeed, hold for **3 seconds** of settle time before `location.reload()` — gives any worker still in graceful shutdown time to finish. The progress bar now flips through "Server responded — confirming health (1/3)…" → "Warming up workers — confirming all responding…" → "All workers responding — reloading in 3 seconds…" so the user sees the extra phase. ### Tests None — frontend timing change; verified by tracing the warmup → settle → reload sequence. ## [3.17.421] - 2026-05-08 ### Fixed — Update hung at "4 of 5 steps complete" forever Two bugs in the Step 5 (Restart Service) progress signaling: 1. **Trigger string mismatch.** `core/updater.py::step_triggers` watched for `Step 5/5: Scheduling` to mark step-5 as started, but `deploy/update_instructions.sh` wrote `Step 5/5: Clearing bytecode cache and reloading service...`. The start trigger never fired, so the UI never even showed step 5 as in-progress. 2. **Reader killed before "Update complete!"** The bash script issues `sudo systemctl reload huduglue-gunicorn.service` inside step 5, which SIGHUPs the very gunicorn worker that's reading the bash subprocess's stdout. The worker shuts down gracefully — but its daemon thread (the one watching for trigger strings) dies with it, BEFORE the bash script gets to log `Update complete!`. So the `complete` trigger never fired either. Result: front-end stuck at 4/5. Fixes: - `deploy/update_instructions.sh` now writes the completed status directly into `/tmp/clientst0r_update_progress_current.json` (atomic `os.replace`) IMMEDIATELY before issuing the reload signal. The front-end poller reads `status: 'completed'` regardless of whether the Python reader survives the restart. - `deploy/update_instructions.sh` also changes the `Step 5/5:` opening line from `Clearing bytecode cache...` to `Scheduling service restart...` so the `'start'` trigger string in `core/updater.py` matches. - `core/updater.py::step_triggers` adds two extra `'complete'` matchers (`Step 5/5: Marked progress`, `Step 5/5: Graceful reload`) as defense-in-depth so even if the bash JSON-write fails, the live log still resolves step 5 if the reader survives. If you're currently looking at a hung "4 of 5 steps" screen, the underlying gunicorn restart most likely DID happen — manual refresh should bring you to the new version. v3.17.421 prevents the screen from hanging on future updates. ### Tests None — bash + signal-handling fix; verified by tracing the restart kill chain. ## [3.17.420] - 2026-05-08 ### Improved — Post-update reload UX The v3.17.418/419 line-of-text countdown didn't make it clear progress was happening, and the 2-minute hard cap was too short on first-time-after-deploy gunicorn restarts. Rewrote `waitForServerThenReload()` in `templates/core/system_updates.html`: - **Visible progress bar** (Bootstrap progress-bar-striped/animated) that fills 0→95% over ~30s of expected restart time, then holds at 95% until the server actually responds. Independent 250ms ticker so the bar keeps moving even if a single probe stalls. - **Live elapsed timer** in the corner ("12s") so you always see the page is alive. - **Manual "Reload now" button** appears after 15 seconds. Gives an escape hatch without making the user wait for the auto-detect. - **5-minute max wait** (was 2 minutes). On expiry, the bar turns red and the message points at `journalctl -u huduglue-gunicorn.service` for diagnosis. - **Two consecutive 200s still required** (avoids catching a single half-up worker), but the message now says "Server responded — confirming health (1/2)…" instead of the cryptic "(#1/2)". - 4s per-fetch abort (was 3s) — slightly more tolerant of a slow first response. ### CodeQL — stale `java` and `cpp` databases purged The repo's CodeQL scan dashboard kept showing a "language:java-kotlin" configuration with errors ("No Java/Kotlin code found") even though the Advanced workflow at `.github/workflows/codeql.yml` only scans `[python, javascript-typescript, actions]` and the Default setup is `not-configured`. The cause was leftover CodeQL `java` (82MB) and `cpp` databases on the repo from before the Advanced workflow took over. Purged via `gh api -X DELETE /repos/agit8or1/clientst0r/code-scanning/codeql/databases/{java,cpp}`. The dashboard's java-kotlin row will clear after the GitHub UI cache refreshes (a few minutes). ### Tests None — frontend UX rewrite + GitHub API cleanup; verified by reading the JS and confirming the API delete returned cleanly. ## [3.17.419] - 2026-05-08 ### Fixed — Update reload countdown stopped after attempt 1 The `waitForServerThenReload()` polling helper from v3.17.418 used `fetch()` with no per-request timeout. When openresty holds the connection open during the gunicorn restart window (instead of returning 503), the fetch never resolves AND never rejects — the countdown freezes at "attempt 1, 1s elapsed" because no `setTimeout(probe, …)` is ever scheduled. Fix: each fetch is now wrapped in an `AbortController` with a **3-second timeout**. If the request stalls, it aborts → `.catch` fires → countdown advances → next probe is scheduled. The timer also clears on success so we don't double-fire. ### Faster — APK build now uses `assembleDebug` The Gradle build was running `./gradlew assembleRelease`, which spends 3–5 minutes on ProGuard/R8 minification + class shrinking + a release keystore step. For internal sideload distribution (this is the only consumer right now), that work is wasted. Switched to `assembleDebug` and added `--daemon --parallel --max-workers=4 --build-cache` for another ~30–60s saving on subsequent builds. Trade-off: debug APKs are larger (no R8 shrinking), signed with the auto-generated debug keystore (still installs cleanly, just shows "from unknown developer" — same as a release build sideloaded outside the Play Store), and are slightly slower at runtime. None of those matter for a few field techs sideloading internally. Will switch back to Release if we ever ship to a public channel. The download view + status pages already point at `app/build/outputs/apk/debug/app-debug.apk` (v3.17.419 also updated `apk_path` in `core/management/commands/build_mobile_app.py` accordingly). ### Tests None — frontend timeout fix + build flag swap; verified by inspecting the JS / mgmt cmd diff. ## [3.17.418] - 2026-05-08 ### Fixed — System Updates Apply showed 503 mid-restart After the update script restarted gunicorn, the modal's JS used a fixed `setTimeout(location.reload, 10000)` to refresh the page. If gunicorn wasn't fully back at the 10s mark, the reload landed on the upstream-down page (openresty 503) and the user assumed the update had failed. Replaced the fixed timer with `waitForServerThenReload()` in `templates/core/system_updates.html`: - Polls `/core/api/update-progress/` every 1s (then every 2s after 10s elapsed) with `cache: 'no-store'`. - Shows a live `Server is restarting — attempt N — waiting for upstream (Ns elapsed)` line under the "Update complete" banner so the user knows we're not frozen. - Requires **two consecutive 200 responses** before reloading — avoids catching a single half-up worker before all workers have rebooted on the new code. - Hard cap of 2 minutes, after which the line flips to `still down after 2 min — [reload manually]` so the user can take action instead of hanging forever. ### Tests None — frontend-only behavior change; verified by reading the polling logic and the `update_progress_api` endpoint. ## [3.17.417] - 2026-05-07 ### Phase 8 — closure Phase 8 (Native mobile apps + GPS auto-time + Timeclock) is now fully shipped. Roadmap header advances to `[shipped — v3.17.417]`; the Sizing-table row reads `shipped v3.17.354–417 (extends Phase 2 + 18 + 21)`. What landed across the train: - **Sub-phase 8.1 — Backend foundation** *(v3.17.397–410)*: `TechnicianLocation` + `ClientSiteGeofence` + `TimeclockEntry` + `MobileDevice` models, plus the five token-authed REST endpoints under `/api/mobile/v1/` (locations, timeclock clock-in/out/me, active-ticket). - **Sub-phase 8.2 — GPS auto-documentation engine** *(v3.17.412)*: `AutoTimePreference` modes (always_on / ask_first / off), `PendingAutoTime` staging, `auto_document_field_visits` mgmt cmd that runs every minute and triggers Web Push on enter/exit transitions. - **Sub-phase 8.3 — Timeclock feature** *(v3.17.413–414)*: web dashboard at `/field-ops/timeclock/` with exception flags + 7-day rollup, payroll CSV export, and a mobile Timeclock screen + dashboard widget in the Expo app. - **Sub-phase 8.4 — App build** *(v3.17.354–360)*: `mobile/` Expo TS scaffold with auth, dashboard, organizations, assets, tickets, KB, vault, monitoring, security, settings, and the new timeclock screen. - **Sub-phase 8.5 — Privacy + safeguards** *(v3.17.411–416)*: `LocationRetentionPolicy` + nightly `prune_technician_locations` cmd, off-shift suppression (`locations_dropped_offshift`), per-tech `/field-ops/my-location-history/` self-serve UI with bulk delete, `OrganizationFieldOpsSettings.geofence_only_mode` + `GeofenceVisit` privacy-preserving alternative to raw lat/lon, and the `/field-ops/settings/` org-admin UI. Tests: 29 in `field_ops/tests.py`, 6 new in `api_mobile.tests.MobileFieldOpsTests`. No code changes in this release — annotation-only. ## [3.17.416] - 2026-05-07 ### Added — Phase 8.5 part 3: org admin retention/privacy UI Closes Sub-phase 8.5. Org admins can now toggle `geofence_only_mode` and adjust `retention_days` from the web UI without going through the Django admin. - `/field-ops/settings/?org=` (org admin / staff / superuser) — form for `geofence_only_mode` toggle + `retention_days` numeric. Auto-creates the `OrganizationFieldOpsSettings` row on first GET. POST saves + audit-logs `org_field_ops_settings_changed` with before/after diff. - Template `templates/field_ops/settings.html`. ### Tests - 2 new tests: non-admin blocked (403), save persists + writes the audit row. ## [3.17.415] - 2026-05-07 ### Added — Phase 8.5: per-tech location history + geofence-only mode Major slice of Sub-phase 8.5 (privacy hardening). Closes parts 1 (per-tech UI) + 2 (geofence-only mode). - `field_ops.OrganizationFieldOpsSettings` — per-org `geofence_only_mode` flag + `retention_days`. When `geofence_only_mode=True`, the locations API endpoint stops persisting raw lat/lon for any geofence-matching ping and instead writes a privacy-preserving `GeofenceVisit` row (geofence id + entered_at + exited_at). - `field_ops.GeofenceVisit` — privacy-preserving alternative to `TechnicianLocation`. Existing TechnicianLocation rows are untouched. - `/api/mobile/v1/locations/` updated: when an inside-ping matches a geofence whose org has `geofence_only_mode=True`, return 201 with `mode=geofence_only` + a `visit_id`. Audit-log `locations_geofence_only_write`. - `/field-ops/my-location-history/` (logged-in user, any role) — paginated list of THE CALLER'S OWN GPS pings. Per-row Delete + bulk **Delete all my history** (requires typing `DELETE` to confirm). All views + actions audit-logged. - Migration `field_ops/0005_orgfopssettings_geofencevisit.py`. ### Tests - 5 new tests: history page lists only my rows; per-row delete on someone else's row 404s; per-row delete on mine works; bulk delete requires confirm word; geofence-only mode writes `GeofenceVisit` (no raw row); outside-fence ping under geofence-only org still writes regular `TechnicianLocation`. ## [3.17.414] - 2026-05-07 ### Added — Phase 8.3 mobile Timeclock screen Closes Sub-phase 8.3. The Expo TS app now has a prominent Timeclock screen + a dashboard widget linking to it. Server-side endpoints already shipped in v3.17.410. - `mobile/app/timeclock/index.tsx` — Clock In / Clock Out screen. If clocked in: shows started timestamp, duration, org/ticket context (read-only), notes field, and a Clock Out button. If not: notes field + Clock In button. - `mobile/src/api/timeclock.ts` — TanStack Query hooks `useTimeclockMe`, `useClockIn`, `useClockOut` wrapping `/api/mobile/v1/timeclock/{me,clock-in,clock-out}/`. - `mobile/app/dashboard.tsx` — adds a Timeclock card at the top of the dashboard. Tapping the card routes to `/timeclock`. - `Stack.Screen name="timeclock/index"` was already registered in `_layout.tsx` (Phase 8.4 scaffolding). ### Tests None — server-side already covered by `api_mobile.tests.MobileFieldOpsTests` (v3.17.410). The screen reuses the typed hook shapes and shared components (`Card`, `Button`, `TextField`, `ListRow`, `Screen`, `ErrorBanner`). ## [3.17.413] - 2026-05-07 ### Added — Phase 8.3 Web Timeclock dashboard + payroll CSV export First half of Sub-phase 8.3. Staff get a dashboard showing who is currently on the clock; payroll runs an export. - `/field-ops/timeclock/` (staff-only) — table of currently-clocked-in techs (tech, org, ticket, started, duration). Per-row exception flags: **long shift >12h** and **missing clock-out >8h**. Pay-period (last 7 days) hours-per-tech rollup beneath. - `/field-ops/timeclock/payroll-export.csv` (staff-only) — last 4 weeks bucketed by `(tech, week_start, organization)`. Columns `tech, week_start, hours, overtime_hours, org`. Compatible with QuickBooks Time / Gusto manual import. - `field_ops/views.py` and template `templates/field_ops/timeclock_dashboard.html` (Bootstrap, no JS). - New `field_ops/urls.py` mounted at `/field-ops/` in `config/urls.py`. ### Tests - 3 new tests: non-staff returns 403, staff dashboard renders with open entries, CSV export has the correct header + tech/org rows. ## [3.17.412] - 2026-05-07 ### Added — Phase 8.2 GPS auto-documentation engine The headline force-multiplier from Phase 8: a tech walks into a client site and a billable timer starts itself. Closes Sub-phase 8.2. - `field_ops.AutoTimePreference` (`OneToOneField(User)`, `mode` choices `always_on` / `ask_first` / `off`, default `ask_first`). - `field_ops.PendingAutoTime` — staging row created when an `ask_first` tech enters a geofence; promoted to a real `TicketTimeEntry` when the tech confirms via the front-end. - `python manage.py auto_document_field_visits [--window-minutes N]` (cron every minute): - For each tech with `mode != 'off'` and a recent (last 5 min) `TechnicianLocation`, walk active `ClientSiteGeofence` rows. - **ENTER (always_on)** → create + start a `TicketTimeEntry` against the user's last-active ticket for that org. Notes start with `[auto-time:field_ops]` so the engine can find its own rows on exit. - **ENTER (ask_first)** → create a `PendingAutoTime` row + emit a Web Push using the existing Phase 21 v9 helper (`WebPushSubscription.send`). - **EXIT** → close every running engine-marked `TicketTimeEntry` for that user. - Audit logs every transition with `extra_data.event = auto_time_*`. - Migration `field_ops/0004_autotimepreference.py`. ### Tests - 5 new tests: off-mode skips, always_on enter creates a running entry, exit closes it, ask_first creates a `PendingAutoTime` (not a running TicketTimeEntry), no-recent-ping skips. ## [3.17.411] - 2026-05-07 ### Added — Phase 8.5 retention: LocationRetentionPolicy + prune mgmt cmd First slice of Sub-phase 8.5 (privacy hardening). Org admins can now bound how long GPS pings live in the database; a nightly mgmt cmd reaps anything past its `retention_until`. - `field_ops.LocationRetentionPolicy` — `OneToOneField(Organization)`, `retention_days` PositiveInt default 90, `apply_to_geofence_only` bool default False (informational flag for v3.17.415's geofence-only mode). Admin registered. - `python manage.py prune_technician_locations [--dry-run]` — deletes `TechnicianLocation` rows where `retention_until < today()`. Prints summary count. Works as a cron-friendly one-shot WHERE clause, no per-org join (the deadline is pre-computed on insert in v3.17.397). - Migration `field_ops/0003_locationretentionpolicy.py`. ### Tests - 4 new tests: defaults applied, expired rows pruned, fresh rows kept, dry-run keeps rows, no-op when nothing expired. ## [3.17.410] - 2026-05-07 ### Added — Phase 8.1 mobile REST surface: locations / timeclock / active-ticket Closes Sub-phase 8.1. Mobile clients can now POST GPS pings, clock in/out, query their open entry, and retrieve their last-active ticket — all over the existing token-auth machinery. - `api_mobile/views_field_ops.py`: - `POST /api/mobile/v1/locations/` — body `{lat, lon, accuracy?, timestamp?}`. Off-shift suppression: if the timestamp falls outside the user's `WorkingHours`, return 204 + audit-log `locations_dropped_offshift` and DO NOT store. On-shift pings save a `TechnicianLocation` and return 201. - `POST /api/mobile/v1/timeclock/clock-in/` — body `{organization_id?, location_id?, ticket_id?, project_id?, notes?}`. 400 if user already has an open clock-in. - `POST /api/mobile/v1/timeclock/clock-out/` — body `{notes?}`. 400 if no open entry. - `GET /api/mobile/v1/timeclock/me/` — current open entry or null. - `GET /api/mobile/v1/active-ticket/` — most recent `TicketTimeEntry` with a null/pending submission. - All endpoints token-authed via the existing `TokenAuthentication` machinery (v3.17.346). ### Tests - 6 new `MobileFieldOpsTests`: location ping during work-hours stored, off-shift ping returns 204 + audit row + no DB row, clock-in then clock-out happy path, double clock-in rejected, clock-out without open returns 400, timeclock_me returns null then populated, active-ticket returns last unsubmitted. ## [3.17.409] - 2026-05-07 ### Added — Phase 8.1 backend foundation (part 2): TimeclockEntry + MobileDevice Second concrete deliverable on the Phase 8 backend train. The remaining REST endpoints land in v3.17.410. - `field_ops.TimeclockEntry` — clock-in / clock-out events with `tech` FK, optional `organization` / `location` / `ticket` / `project` FKs, `clocked_in_at`, nullable `clocked_out_at`, `source` choices (`mobile` / `web` / `manual`), and `notes`. A partial unique constraint enforces one open clock-in per tech. - On clock-out (`clocked_out_at` becomes non-null) AND a ticket is attached, the model automatically derives a `psa.TicketTimeEntry` row so existing billing rolls up unchanged. The derived entry is tracked via `derived_time_entry` so a re-save doesn't double-bill. - `field_ops.MobileDevice` — registered mobile devices for long-lived bearer auth. UUID `device_id` (unique), platform (`ios` / `android`), name, optional FK to `authtoken.Token` for revoke-on-demand, `last_seen_at`, `revoked` flag. - Admin registration for both models. - Migration `field_ops/0002_timeclockentry_mobiledevice.py`. ### Tests - 5 new tests: TimeclockEntry open-state behavior, partial-unique-constraint enforcement, clock-out-with-ticket derives a TicketTimeEntry, clock-out-without-ticket skips derivation; MobileDevice creation + device_id uniqueness. ## [3.17.408] - 2026-05-07 ### Fixed — APK build failed in 12s with "SDK location not found" The Gradle build was bailing immediately because `ANDROID_HOME` wasn't set in the subprocess env, so it couldn't find the Android SDK. SDK is installed at `/home/administrator/android-sdk/` (platforms 34 + build-tools 33/34). Two changes in `core/management/commands/build_mobile_app.py`: - Auto-detect SDK location (first `$ANDROID_HOME`, then `$ANDROID_SDK_ROOT`, then candidate paths). Set both env vars before running any subprocess. Also prepend `platform-tools`, `cmdline-tools/latest/bin`, and `build-tools/34.0.0` to `PATH`. - Belt-and-suspenders: write `mobile/android/local.properties` with `sdk.dir=$ANDROID_HOME` right before `./gradlew assembleRelease`. Some Gradle wrappers honor only the file, not the env var. ### What this doesn't fix yet The build log also showed a SECOND error from `expo-modules-core/android/ExpoModulesCorePlugin.gradle` line 85: `Could not get unknown property 'release' for SoftwareComponent container`. That's the long-standing `expo-module-gradle-plugin` compat issue documented in memory from Feb 2026. Worth attempting the build again with v3.17.408 — sometimes the SDK-not-found error masks fixable downstream issues. If the second error still fires after Apply + Rebuild + Build: 1. Capture the build log tail (visible on the live Build status page). 2. Send it back; we can either patch the Expo modules manifest or fall back to **Expo Go** as the dev/test path (`cd mobile && npm start`, scan QR with Expo Go from Play Store). ### Tests None — env-var fix; verified by inspecting the `mobile/android/local.properties` write path. ## [3.17.398] - 2026-05-07 ### Fixed — APK build was using the wrong (legacy) codebase **The 20MB APK that "Download APK" served was from Feb 26, 2026 — three months old.** The `build_mobile_app` management command was set to build from the legacy `mobile-app/` skeleton (created Apr 28, 2026 — predates Phase 8). The new Expo SDK 51 + TypeScript app shipped via Phase 8 v3.17.354–360 lives at `mobile/`, but the build pipeline never pointed there. Every "Download APK" / "Build & download APK" click served the same cached-from-Feb-2026 binary that has none of the recent fixes (including the v3.17.385 Keystore cold-start crash fix). Two changes: - `core/management/commands/build_mobile_app.py` — `mobile_app_dir` now points at `mobile/` (the Expo TS app). Falls back to `mobile-app/` only if `mobile/` is somehow missing. Output path stays at `mobile-app/builds/` so the existing `download_mobile_app` view keeps working without changes. - `core/views.py::mobile_apps_admin` — adds a POST `?action=rebuild&platform=` handler that deletes the cached binary + status files. The template now shows a **Rebuild from latest code** button next to the Download button when an APK is present. Clicking it wipes the cache, audit-logs `mobile_app_rebuild_requested`, and redirects back; the next "Build & download" click triggers a fresh compile from the current `mobile/` source tree. To get the APK with the v3.17.385 Keystore fix: 1. Apply v3.17.398. 2. Open Admin → Mobile → Mobile Apps. 3. Click **Rebuild from latest code** (Android card). Confirm. 4. Click **Build & download APK** — first build will take ~10–20 min (npm install + Gradle); subsequent builds 3–5 min. 5. Sideload the new APK and re-test. ### Tests None — direct fix; verified by hitting the Rebuild action and confirming the cached APK is wiped. ## [3.17.397] - 2026-05-07 ### Added — Phase 8.1 backend foundation: TechnicianLocation + ClientSiteGeofence First server-side concrete deliverable for the Phase 8 train (originally targeted v3.17.386; bumped to v3.17.397 to stay above the v3.17.396 monotonicity-fix release). The other half of Sub-phase 8.1 (TimeclockEntry + MobileDevice + the locations/timeclock/active-ticket REST endpoints) lands in the next two releases. - New Django app `field_ops/` with `default_auto_field = BigAutoField`, registered in `INSTALLED_APPS`. - `TechnicianLocation` model — append-only GPS ping with tech FK, lat/lon (Decimal 9,6), accuracy (meters), timestamp, source choices (`mobile` / `web`), and a pre-computed `retention_until` date so the (forthcoming v3.17.400) prune mgmt cmd is a one-shot WHERE clause. - `ClientSiteGeofence` model — per-organization geofence supporting both `radius` (center + meters) and `polygon` (list of [lat, lon] vertices) modes. Optional `location` FK keeps Phase 18 multi-location working. Includes a `contains(lat, lon)` method using equirectangular distance for radius mode and ray casting for polygon mode. - Admin registration for both models. - Migration `field_ops/migrations/0001_initial.py`. ### Tests - 6 new tests: TechnicianLocation default retention applied, explicit retention preserved; ClientSiteGeofence radius-contains, radius-excludes, polygon-contains, admin str. ## [3.17.396] - 2026-05-07 ### Fixed — Update flow stuck in "Update Available" loop Same class of bug as v3.17.333. Two parallel agents (Backend Mobile API + Phase 8 closeout) shipped with non-overlapping version ranges, but the Backend Mobile API agent's range (345–353) ended up below the Phase 8 closeout agent's range (385–395). Phase 8 v3.17.385 (the APK cold-start fix) shipped first chronologically, then the Mobile API agent kept landing v3.17.349/350 with LOWER version numbers via push-race rebase. Result: after Apply, `config/version.py` ended up at 3.17.350 even though every committed-tag commit (including v3.17.385) was already an ancestor of HEAD. The updater compares `VERSION` (3.17.350) against the highest tag (v3.17.385), sees a mismatch, and shows "Update Available" forever — clicking Apply again does nothing visible because the script is already at the latest commit. This release bumps `VERSION` to **3.17.396** — past every existing tag — so `Settings → Updates` reports the install as up-to-date. No code or schema change. ### Note — Backend Mobile API agent stopped The Backend Mobile API agent was running in version range 345–353 and had completed 6 of 9 endpoint releases (auth, dashboard, organizations, assets, tickets, KB). The remaining 3 endpoints (vault reveal, monitoring/security/profile, throttling+tests) were stopped to break the non-monotonicity loop. They will be picked up in a follow-up release at HIGHER version numbers (v3.17.397+). ### Tests None — pure version bump. ## [3.17.350] - 2026-05-07 ### Added — Mobile API: knowledge-base endpoints Sixth release in the mobile-API train. KB articles back the read surface for techs in the field. - New endpoint `GET /api/mobile/v1/kb/?search=&page=` — paginated. Returns articles where (`is_global=True` OR `organization_id` is in the user's accessible orgs) AND `is_published=True` AND `is_archived=False`. Search matches `title`, `body`, `slug`. - New endpoint `GET /api/mobile/v1/kb//` — detail with both raw `body` (markdown / HTML source) and `body_html` (rendered + sanitised via `Document.render_content()` which uses bleach). Cross-org reads return 404. ### Tests - 5 new tests: list requires auth, list returns global + my-org articles (cross-org excluded), search narrows, detail returns body + rendered HTML, detail cross-org blocked. ## [3.17.385] - 2026-05-07 ### Fixed — Mobile APK crashes immediately after biometric unlock Real-world Android crash report: "makes me use unlock pattern or bio as soon as it opens, then immediately closes. I haven't even put in server details or login info." Root cause: on Android cold start the Keystore is briefly locked while the user authenticates, and `SecureStore.getItemAsync` throws a native exception during that window. The unhandled rejection inside the boot path was bubbling out of `useEffect` and the OS killed the process before any UI could mount. - `mobile/src/utils/storage.ts` — added a `bootstrap()` helper that wraps token + server URL reads in defensive try/catch and never throws on Keystore-not-yet-ready situations. - `mobile/app/_layout.tsx` — boot sequence now uses `bootstrap()` and treats any storage exception as "no token, continue to /login". Wrapped `` in the new `` so any uncaught render-tree error shows a friendly fallback instead of crashing the app process. - `mobile/app/index.tsx` — same defensive wrapping plus a try/catch on the `router.replace()` call (in case the router isn't ready yet). - `mobile/src/components/ErrorBoundary.tsx` — new top-level error boundary with a "Try again" button. ### Tests None — pure mobile-app boot-path hardening; verified by reading the updated files and confirming no static-analysis regression. (Native test would require running the Android emulator, which isn't available in CI.) ## [3.17.349] - 2026-05-07 ### Added — Mobile API: tickets endpoints Fifth release in the mobile-API train. Full read/create/patch surface plus comment add — covers a tech's daily field workflow. - New endpoint `GET /api/mobile/v1/tickets/?status=&priority=&assigned_to_me=true&organization_id=&search=&page=` — paginated. `status=open` matches all non-terminal statuses; `status=closed` matches `is_terminal=True`; otherwise matches `TicketStatus.slug`. `priority` matches `TicketPriority.code` (e.g. `P1`). - New endpoint `POST /api/mobile/v1/tickets/` — create. Requires `organization_id` + `subject`; `description`, `requester_name`, `requester_email` optional. Cross-org create returns 403. Defaults pulled from existing seed data (status=new, priority=P3, first TicketType + Queue) — returns 409 if PSA seed data is missing. - New endpoint `GET /api/mobile/v1/tickets//` — detail with embedded comments thread (ordered by `created_at`). - New endpoint `PATCH /api/mobile/v1/tickets//` — partial update of `status_id` / `priority_id` / `assigned_to_id` (use `null` / `0` to unassign). Cross-org returns 404. - New endpoint `POST /api/mobile/v1/tickets//comments/` — add a comment. Body required; optional `is_internal` flag. `source='api'` recorded on the row. ### Tests - 8 new tests: list scoped to user orgs, detail returns my ticket with comments, detail cross-org blocked (404), create happy path, create cross-org rejected (403), patch status, add comment, list requires auth. ## [3.17.382] - 2026-05-07 ### Fixed — Mobile Apps page hard to read on dark theme The custom `.platform-card` div had a 1px border but no background, so on the dark theme it inherited the page background and the card content blended into the surrounding chrome. Switched to Bootstrap's `
` + `
` pair so each platform tile now gets a proper themed background that contrasts with the page. Also tightened the inline-`` and status-pill foreground colors via `--bs-emphasis-color` / `--bs-*-text-emphasis` for stronger contrast in both light and dark themes. ### Tests None — pure CSS / template structure change. ## [3.17.381] - 2026-05-07 ### Fixed — Mobile Apps admin page 500 The v3.17.380 view used `django.utils.timezone.utc` to format the APK build mtime, which was removed in Django 5. We're on Django 6 — page returned 500 with `AttributeError: module 'django.utils.timezone' has no attribute 'utc'`. Replaced with stdlib `datetime.timezone.utc`. ### Tests None — single-line fix; verified by hitting `/core/mobile-apps/` after restart. ## [3.17.348] - 2026-05-07 ### Added — Mobile API: assets endpoints Fourth release in the mobile-API train. - New endpoint `GET /api/mobile/v1/assets/?search=&organization_id=&type=&page=` — paginated list scoped to the user's accessible organizations. Search matches `name`, `hostname`, `ip_address`, `serial_number`, `asset_tag`. Filters: `organization_id` (rejected if not in caller's accessible orgs), `type` (asset_type slug). - New endpoint `GET /api/mobile/v1/assets//` — detail. Cross-org reads return 404. Detail body includes `mac_address`, `os_name`, `os_version`, `manufacturer`, `model`, `warranty_status`, `created_at`. ### Tests - 6 new tests: list scoped to user orgs (cross-org rows excluded), detail returns my asset, detail cross-org blocked (404), list search by hostname narrows results, list filter by `type`, list requires auth. ## [3.17.380] - 2026-05-07 ### Added — Admin "Mobile Apps" landing page for sideload distribution New page at `/core/mobile-apps/` linked from the Admin nav dropdown (under a new "Mobile" section). Renders a two-card layout: - **Android** card: build status pill (Ready / Building / Not built), file size + age when available, "Download APK" or "Build & download APK" button (re-uses the existing `core:download_mobile_app` view that auto-builds via `npx expo prebuild` + Gradle on first request), and a 4-step sideload guide. - **iOS** card: explains that iOS sideload requires Mac/Xcode or AltStore/Sideloadly (no Linux server-side build path produces a sideloadable IPA). Lists 4 distribution paths (Expo Go for dev, free 7-day cert via Sideloadly, Apple Developer ad-hoc, App Store) so the operator can pick the right one. External links to AltStore and Sideloadly. - A "Build pipeline" footer card surfaces the source path (`mobile/`), output paths, and status-JSON paths so admins can debug without SSH. The page is gated by the existing `is_staff or is_superuser` pattern via `@user_passes_test`. ### Version-numbering note This release is intentionally numbered v3.17.380 (skipping ~33 patch numbers above the latest pushed v3.17.363 + the in-flight backend-mobile-API agent's range that tops at 353). The bump avoids version-vs-tag collisions with the still-running parallel agent. A monotonicity-fix release will follow once all parallel work finishes. ### Files - `core/views.py` — new `mobile_apps_admin` view - `core/urls.py` — new `core:mobile_apps_admin` URL at `/core/mobile-apps/` - `templates/core/mobile_apps_admin.html` — new page - `templates/base.html` — Admin dropdown gets a "Mobile" section with the new link ### Tests None — pure UI/admin landing page; the underlying download view is already covered. ## [3.17.347] - 2026-05-07 ### Added — Mobile API: dashboard + organizations endpoints Third release in the mobile-API train. - New endpoint `GET /api/mobile/v1/dashboard/` — headline counts the mobile dashboard renders: open tickets, critical (P1) tickets, expirations within 30 days, offline website monitors, recent assets (7 days), open security alerts, recent audit-log activity (24 h), and `organization_count`. Counts are scoped to the user's accessible organizations. Each model lookup is defensive — if a model / app isn't loaded the count falls back to 0 instead of 500. - New endpoint `GET /api/mobile/v1/organizations/?search=&page=` — paginated list of orgs the user has an active `Membership` in. Supports search by name / slug. - New endpoint `GET /api/mobile/v1/organizations//` — detail with related counts (assets, open tickets, contacts). Cross-org reads return 404. - New helper `api_mobile/scoping.py::accessible_org_ids(user)` — single source of truth for "which orgs can this user see" used by all subsequent endpoints in the train. ### Tests - 6 new tests: dashboard requires auth, dashboard returns counts dict, org list requires auth, org list scoped to user's orgs, org detail returns my org, org detail cross-org read blocked (404). - Updated test class decorators to disable DRF rate-throttling — cumulative login attempts across multiple test classes sharing one IP otherwise tripped the 10/hour login throttle. ## [3.17.363] - 2026-05-07 ### Changed — Phase 23 close Phase 23 (Security Event & Incident Workflows) closeout. All 12 sub-bullets now carry `*(shipped vN.N.N)*` annotations and the phase header is now `[shipped — v3.17.363]` so the JSON roadmap feed at `/core/roadmap.json` reports the correct status. Sizing-table row updated to "shipped v3.17.337–363 (extends Phase 9 + 17)". Phase 23 features delivered (over 8 releases v3.17.337 → v3.17.363): - v3.17.337 — SIEM webhook adapter (CEF/JSON/Syslog). - v3.17.338 — `SecurityIncident` + `SecurityIncidentEvent` + auto-correlation by asset/severity window. - v3.17.339 — Per-organization cached `exposure_score` + `recompute_exposure_scores` mgmt cmd. - v3.17.355 — `SecurityIncidentSLAPolicy` + `check_incident_sla_breaches` mgmt cmd (idempotent timeline events). - v3.17.358 — `RemediationPlaybook` + `RemediationPlaybookStep` engine; auto-fires on incident open. - v3.17.361 — `/security/threat-overview/` single-pane analyst dashboard. - v3.17.362 — OPTIONAL AI incident summarization gated by `psa_ai_enabled`. The remaining sub-bullets (Security event ingestion, Security dashboarding, Security event reporting, Vulnerability correlation, CVE-to-ticket workflows) shipped via prior Phase 9 / Phase 17 work and carry cross-phase shipping annotations. ### Tests None — pure docs / metadata commit. ## [3.17.362] - 2026-05-07 ### Added — Phase 23 v7: AI-assisted incident summarization (OPTIONAL AI) Seventh Phase 23 release. **OPTIONAL AI** — gated by `SystemSetting.psa_ai_enabled`. Given a `SecurityIncident`, produce a 1-paragraph executive summary plus suggested next steps. The summarizer is pluggable via `set_provider(...)`; the default heuristic implementation is deterministic (no network) so the feature degrades gracefully when no LLM is configured. A new POST endpoint `/security/incidents//ai-summarize/` triggers summarization and stores the result as a timeline note. - New module `security_alerts/ai_summarizer.py` with `is_ai_enabled()`, `summarize_incident(incident, requested_by=...)`, and `set_provider(callable)`. - New view `incident_ai_summarize` — when AI is disabled returns a flash error; when enabled, produces a summary and posts it to the incident timeline as a `note` event. - Provider-call layer is mockable so production deployments can swap in the existing `psa_ai/services` Anthropic plumbing. ### Tests - 3 new tests covering the gate-off branch (no provider call, no timeline write), gate-on happy path (summary + timeline note recorded), and provider-error handling. ## [3.17.361] - 2026-05-07 ### Added — Phase 23 v6: Threat visibility dashboard Sixth Phase 23 release. New single-pane analyst view at `/security/threat-overview/` rolling up open alerts by severity, open incidents by severity, top-exposed organizations (cached `exposure_score`), in-flight playbook activity in the last 24 hours, and a 7-day-vs-prior-7-day week-over-week alert-volume trend. - New view `threat_overview` and template `templates/security_alerts/threat_overview.html`. - Safe with empty data (zero alerts, zero incidents, zero exposure scores) — renders the page with placeholders. - Reuses cached `Organization.exposure_score` for the top-exposed table. ### Tests - 4 new tests covering empty render, alert counts, top-exposed inclusion, and the no-prior-week WoW edge case. ## [3.17.360] - 2026-05-07 ### Added — Phase 8 mobile app v5: Docs + EAS placeholder + roadmap close Final mobile-app release in this train. Documentation + production-build scaffolding so an operator can run `eas init && eas build` once they have Apple Developer + Play Console accounts. - New `mobile/README.md` — full setup (prereqs, local dev, backend URL examples for iOS sim / Android emulator / LAN device, type-check command, EAS production-build path, signing-keys checklist, security notes about token storage and vault-secret handling). - New `mobile/eas.json` — `development` / `preview` / `production` build profiles. Submit-config keys for App Store Connect + Play Console are placeholders that operators fill in. - New `mobile/assets/icon.png`, `mobile/assets/splash.png`, `mobile/assets/adaptive-icon.png`, `mobile/assets/favicon.png` — solid-color 1024×1024 placeholders generated programmatically. Replace before store submission. - `mobile/app.json` — `extra.eas.projectId` placeholder so `eas init` can fill it in cleanly. - `docs/MOBILE_APP_PLAN.md` — appended a "Mobile app shipped" section with the per-release breakdown, screens delivered, and what remains deferred (GPS auto-time, timeclock UI, push notifications, biometric unlock, Azure SSO). - `docs/ROADMAP.md` Phase 8 sub-phase 8.4 annotation completed; phase header stays `[in progress]` because Sub-phases 8.2 (GPS auto-time), 8.3 (timeclock), and 8.5 (privacy hardening) are explicitly deferred. ### Tests - `cd mobile && npx tsc --noEmit` clean. ## [3.17.359] - 2026-05-07 ### Added — Phase 8 mobile app v4: Vault + Monitoring + Security + Settings Fourth mobile-app release. Closes the read-heavy MSP-field surface — secure vault reveal flow, website-monitor + expirations dashboard, security alert summary, and account/server/theme settings. - New screens: - `app/vault/index.tsx` (list — never shows secrets, flags entries that need approval). - `app/vault/[id].tsx` (detail + secure reveal flow): - Reveal is gated by a confirmation modal. - 200 response shows the secret in component state ONLY — never SecureStore, never AsyncStorage, never logged. - 202 response (approval required) surfaces the request URL instead of the secret. - Optional copy-to-clipboard auto-clears in 30 s and warns the user. - `expo-screen-capture.preventScreenCaptureAsync` engaged while a secret is on screen (best-effort; iOS cannot fully suppress). - Secret + clipboard cleared on unmount. - `app/monitoring/index.tsx` (website monitors + upcoming expirations with status pills). - `app/security/index.tsx` (severity-bucketed alert tiles + recent-alerts list). - `app/settings/index.tsx` (profile edit via PATCH /profile/, server URL, theme override stored in AsyncStorage, logout, clear-local-data, app version display). - New TanStack Query hooks: `useVaultEntries`, `useVaultEntry(id)`, `useRevealVaultSecret(id)`, `useMonitors`, `useExpirations`, `useSecuritySummary`, `useProfile`, `useUpdateProfile`. - Reveal hook deliberately performs no `onSuccess` cache write — secrets are not cached anywhere on disk. - Renumbered to v3.17.359 because Phase 23 closeout took v3.17.358. - No Django code touched. `cd mobile && npx tsc --noEmit` clean. ### Tests - `cd mobile && npx tsc --noEmit` clean. ## [3.17.358] - 2026-05-07 ### Added — Phase 23 v5: Remediation playbook engine Fifth Phase 23 release. New `RemediationPlaybook` + `RemediationPlaybookStep` models drive automated remediation flows on `SecurityIncident` rows. When an incident is opened by alert correlation, the highest-priority active playbook matching the incident's severity (and optional client_org) fires and runs each step in order. Step results stream to the incident timeline as `playbook_action` events. - New model `security_alerts.RemediationPlaybook` — trigger conditions (severity ≥ + optional client scope), priority ordering, active flag. - New model `security_alerts.RemediationPlaybookStep` — ordered actions: `create_ticket` / `send_email` / `quarantine_asset_flag` / `run_workflow_rule`. - New helpers `find_matching_playbook(incident)` and `execute_playbook(playbook, incident, dry_run=False)` — error-isolated step dispatch; one failed step won't halt the rest. - `quarantine_asset_flag` adds an Asset to a `security-quarantine` Tag for the incident's organization. - Wired into `_correlate_alert_to_incident` so newly opened incidents fire their matching playbook automatically. - New view `/security/playbooks/` lists playbooks + steps + active status. - New migration `security_alerts/0005_remediationplaybook_remediationplaybookstep.py`. ### Tests - 5 new tests covering severity-min matching, priority ordering, ticket creation, dry-run side-effect-free behavior, and unknown-action handling. ## [3.17.357] - 2026-05-07 ### Added — Phase 8 mobile app v3: Tickets + Knowledge Base Third mobile-app release. Adds the workflow surface for techs in the field — review, triage, comment, and create tickets, plus search-and-read of the knowledge base. - New screens: `app/tickets/index.tsx` (filter chips: open / mine / critical / closed / all + search), `app/tickets/[id].tsx` (detail + status + priority chip-pickers + comments thread + add-comment form with optional internal flag), `app/tickets/new.tsx` (create form), `app/kb/index.tsx` (search), `app/kb/[id].tsx` (article render via `react-native-markdown-display`; HTML articles fall back to plain text — TODO add `react-native-render-html` if needed). - New TanStack Query hooks: `useTickets(args)`, `useTicket(id)`, `useCreateTicket`, `useUpdateTicket(id)`, `useAddComment(id)`, `useKBArticles(search)`, `useKBArticle(id)`. Mutations invalidate the right cache keys so the dashboard counters refresh on status changes. - `auth.ts` `me()` now hits `/auth/me/` to match backend v3.17.346 (was `/profile/`); `/profile/` is reserved for the editable Settings screen in v3.17.359. - No Django code touched. `cd mobile && npx tsc --noEmit` clean. ### Tests - `cd mobile && npx tsc --noEmit` clean. ## [3.17.356] - 2026-05-07 ### Added — Phase 8 mobile app v2: Dashboard + Organizations + Assets Second mobile-app release. Adds the read-heavy MSP-field views needed for daily use. All screens consume the `/api/mobile/v1/` endpoints the backend agent is shipping in v3.17.345-353. - New screens: `app/dashboard.tsx` (stat tiles + recent tickets / assets / alerts with pull-to-refresh), `app/organizations/index.tsx` (search + list), `app/organizations/[id].tsx` (detail + related-counts shortcuts), `app/assets/index.tsx` (list + filters: org / type / status), `app/assets/[id].tsx` (detail). - New TanStack Query hooks: `useDashboard`, `useOrganizations`, `useOrganization(id)`, `useAssets(filters)`, `useAsset(id)`. - New reusable components: `Card`, `StatTile`, `ListRow`, `StatusPill` (with severity / ticket-status / monitor-status helpers). - Stat tiles act as drilldowns into Tickets / Monitoring / Security screens (full implementations land in v3.17.357 + v3.17.358). - Filters and search debounce naturally via React Query's `queryKey`-based caching. - No Django code touched. `cd mobile && npx tsc --noEmit` is clean under strict mode. ### Tests - `cd mobile && npx tsc --noEmit` clean. ## [3.17.346] - 2026-05-07 ### Added — Mobile API: DRF setup + auth endpoints Second release in the mobile-API train. New `api_mobile/` Django app under `/api/mobile/v1/` with token-authenticated auth endpoints. DRF and `rest_framework.authtoken` were already installed and migrated, so no new dependencies or model migrations are needed for this release. - New endpoints (all under `/api/mobile/v1/auth/`): - `POST /login/` — username/email + password → `{token, user}`. If the user has 2FA enabled, returns `{mfa_required: true, mfa_token}` and requires a follow-up `/mfa/` call. - `POST /mfa/` — `mfa_token + code` → `{token, user}`. Validates against `django_otp.plugins.otp_totp` confirmed devices. - `POST /logout/` — revokes the caller's token. - `GET /me/` — returns `{user: {id, username, email, full_name, organization_id, role}}`. - `POST /refresh/` — rotates the caller's token (old token revoked, new one issued). - Login is throttled at 10/hour per IP via the existing `login` scope. Failed logins continue to feed `django-axes` IP lockout (the existing auth backend chain runs unchanged). - Every login / MFA / logout / refresh writes an `AuditLog` with `extra_data.channel='mobile'`. Failed logins audit as `login_failed` with the offending username and the rejection reason (`invalid_credentials` / `bad_totp` / `inactive`). - The MFA challenge token is opaque, single-use, kept in Django's default cache for 5 minutes, and consumed atomically. ### Tests - 9 new tests in `api_mobile/tests.py` covering login success, wrong password, missing fields, 2FA-required branch (`mfa_required: true` + opaque `mfa_token`), bad-MFA-token rejection, unauthenticated `/me/` blocked, authenticated `/me/` returns profile, `logout` revokes token, `refresh` rotates token. ## [3.17.355] - 2026-05-07 ### Added — Phase 23 v4: Incident SLA tracking Fourth Phase 23 release. Security incidents now carry SLA targets (acknowledge / contain / resolve) via a new `SecurityIncidentSLAPolicy` model. Targets are matched on (organization, client_org, severity) with the most-specific (client-pinned) policy winning over MSP-wide. A breach checker walks every open incident and writes idempotent `sla_breach` timeline events when a target deadline passes without being met. - New model `security_alerts.SecurityIncidentSLAPolicy` — minutes-to-acknowledge / contain / resolve targets per (org, optional client, severity), unique together. - New helpers `policy_for_incident(incident)` and `evaluate_incident_breaches(incident)` in `security_alerts.models`. Breach evaluation is idempotent — re-running won't double-record the same target. - New mgmt cmd `manage.py check_incident_sla_breaches [--org-id=N]`. - New migration `security_alerts/0004_securityincidentslapolicy.py`. ### Tests - 5 new tests covering inside-window happy path, acknowledge-overdue, idempotency, met-inside-target, and the management command. ## [3.17.354] - 2026-05-07 ### Added — Phase 8 mobile app v1: scaffold + auth client First release of the Expo React Native + TypeScript client under `mobile/`. Targets the `/api/mobile/v1/` backend the concurrent backend agent is shipping in v3.17.345–353. - New `mobile/` Expo SDK 51 project with TypeScript strict-mode, Expo Router file-based routing, TanStack Query, axios, zod. - Server URL + auth token persisted in `expo-secure-store`. Vault secrets are NEVER persisted, never logged. - Login screen supports server URL override, email/password, and the MFA second-step flow that the backend `/auth/login/` + `/auth/mfa/` endpoints emit. - API client at `mobile/src/api/client.ts` with auth-header injection and 401 -> re-login handler. - Typed serializer mirrors at `mobile/src/types/api.ts` for User, Organization, Asset, Ticket, KBArticle, VaultEntry, Monitor, ExpirationItem, SecuritySummary, DashboardSummary. - Reusable components — `Screen`, `TextField`, `Button`, `ErrorBanner`. - Does NOT touch any Django code; no migrations, no urls. Web app behavior is unchanged. ### Tests - `cd mobile && npx tsc --noEmit` passes with strict mode. - Backend API tests are owned by the concurrent mobile-API release. ## [3.17.345] - 2026-05-07 ### Added — Mobile API plan doc (Phase 8 prep) First of the v3.17.345 → v3.17.353 mobile-API release train. Pure docs commit. Adds `docs/MOBILE_APP_PLAN.md` describing the planned `/api/mobile/v1/` surface, token-auth + 2FA flow, vault-reveal security rules (GeoIP / Axes / `VaultAccessRule` / `requires_reveal_approval` all preserved), throttling, CSRF posture, and a per-release endpoint table. Companion document to Phase 8 in `docs/ROADMAP.md`. The actual endpoints + tests land starting v3.17.346. ### Tests None — pure docs. ## [3.17.339] - 2026-05-07 ### Added — Phase 23 v3: Exposure scoring Third Phase 23 release. Each Organization now carries a cached `exposure_score` (0–1000) computed from open SecurityAlerts (severity-weighted), open SecurityIncidents, open Vulnerabilities (per-org and global advisories), plus an asset-count surface-area bonus. The score is recomputed in batch by `manage.py recompute_exposure_scores` (cron-friendly) and surfaced as a colored badge on the organization detail page. - New fields `core.Organization.exposure_score` + `exposure_score_updated_at`. - New module `security_alerts/exposure.py` — pure-function scoring with `compute_exposure_score(org)` and `recompute_for_org(org)`. - New mgmt cmd `manage.py recompute_exposure_scores [--org-id=N] [--dry-run]`. - Severity weights: SecurityAlerts critical=25 / high=12 / medium=5 / low=2 / info=1; open Incidents 2× alert weight; Vulnerabilities critical=30 / high=15 / medium=6 / low=2; asset-count bonus +1 per 5 assets capped at +50; total capped at 1000. - New migration `core/0060_organization_exposure_score_and_more.py`. - Org detail template card shows a green / amber / red badge and last-updated timestamp. ### Tests - 5 new tests covering zero-state, severity weighting, resolved-alert exclusion, recompute persistence, and the management command. ## [3.17.338] - 2026-05-07 ### Added — Phase 23 v2: Security incident model + timelines Second Phase 23 release. New `SecurityIncident` + `SecurityIncidentEvent` models group related `SecurityAlert` rows into analyst-facing case files with a chronological timeline. Auto-correlation rule: a fresh alert merges into an open incident when (organization, asset_hint, severity) match within a 60-minute window; otherwise a new incident is opened anchored by the alert. Manual notes + status transitions are recorded as timeline events. - New model `security_alerts.SecurityIncident` — 5-state status machine (open / investigating / contained / resolved / closed), severity inherited from `SecurityAlert`, M2M to alerts, optional `assigned_to`. - New model `security_alerts.SecurityIncidentEvent` — typed timeline entries (opened, alert_added, note, status_change, acknowledged, contained, resolved, closed, playbook_action, sla_breach). - New helper `_correlate_alert_to_incident(alert, window_minutes=60)` — wired into the vendor sync, vendor webhook, and SIEM webhook ingest paths so every newly created `SecurityAlert` flows into the incident store automatically. - New views: `/security/incidents/` (list + filters), `/security/incidents//` (timeline + linked alerts + status buttons), POST `/security/incidents//decide/` for transitions and analyst notes. - New migration `security_alerts/0003_securityincident_securityincidentevent_and_more.py`. ### Tests - 7 new tests covering correlation (open new / attach to existing / different-severity branches), `add_event` helper, resolved-incident branching, plus view tests for detail render and acknowledge transition. ## [3.17.337] - 2026-05-07 ### Added — Phase 23 v1: SIEM webhook adapter (CEF/JSON/Syslog) First Phase 23 release. New `SIEMWebhookEndpoint` model exposes a per-token inbound endpoint at `/security/siem/webhook//` that accepts CEF (ArcSight Common Event Format), generic JSON, or syslog-wrapped CEF. Inbound events are normalized into the existing `SecurityAlert` schema so the triage UI, auto-ticket rules, and downstream Phase 23 incident workflows just work. - New model `security_alerts.SIEMWebhookEndpoint` — per-organization endpoint with auto-generated token + HMAC secret, optional `require_hmac` enforcement, configurable expected format, default severity fallback. - New CEF parser at `security_alerts/siem.py` — `parse_cef_line` handles escaped pipes in headers, `_split_cef_extension` extracts key=value pairs preserving spaces, severity 0–10 maps to `low/medium/high/critical` buckets. - New view `siem_webhook_receive` — 404 for unknown tokens, 403 for invalid/missing-when-required signatures, 200 with `{received, imported}` JSON on success. Dedupes on `(siem_endpoint, external_id)`. - New URLs: `/security/siem/` (CRUD list), `/security/siem/new/`, `/security/siem//edit/`, `/security/siem/webhook//`. - `SecurityAlert.connection` is now nullable; new `SecurityAlert.siem_endpoint` FK + dedupe index. Vendor-connection alerts continue to dedupe on `(connection, external_id)`. ### Tests - 7 new tests covering CEF parser, severity bucketing, unknown-token 404, invalid HMAC 403, valid HMAC accept, dedupe on repeat ingestion, JSON payload happy path, require_hmac enforcement. ## [3.17.336] - 2026-05-07 ### Removed — Phase 29 deleted from roadmap Same treatment as Phases 24 + 30 in v3.17.335. Phase 29 (Commercial Operations Ecosystem — tiered support tiers, paid onboarding, SOC 2 readiness, reseller program, etc.) is removed from the roadmap entirely. Roadmap now jumps Phase 28 → Phase 31. Sizing-table row dropped. ### Tests None — pure roadmap edit. ## [3.17.335] - 2026-05-07 ### Removed — Phase 24 + Phase 30 deleted from roadmap v3.17.334 marked these `[wont-do]` while keeping the original sub-bullets for context. Per follow-up: just delete them — they shouldn't appear at all. The roadmap (in-app + GitHub + JSON feed) now jumps directly Phase 23 → Phase 25 and Phase 29 → Phase 31. The sizing-table rows are also dropped, and the closing scope-note is reworded as a positive statement of the project's domain rather than an apology for the deletion. The `[wont-do]` parser support added in v3.17.334 stays in place — useful if a future phase needs the marker. ### Tests None — pure roadmap edit. ## [3.17.334] - 2026-05-07 ### Changed — Phase 24 + Phase 30 cancelled (won’t do) Both phases are removed from the active roadmap. The project remains a PSA + Asset + Vault + Documentation + Monitoring platform that integrates with external RMMs via the Phase 9 connection framework and Phase 7 Integration SDK. Building a first-party RMM agent (Phase 24) or remote-access relay (Phase 30) is intentionally not on the roadmap. ### Added — `[wont-do]` phase status - `core/views.py::roadmap()` HTML classifier recognizes `[wont-do]`, `[won't do]`, `[out-of-scope]` and emits `data-phase-status="wont-do"` on the H2 with a "Won’t do" badge. - `core/views.py::roadmap_status_json()` JSON parser maps the same brackets to `status: "wont_do"` so external dashboards / status pages don't need to special-case them. - `templates/core/roadmap.html` adds a strikethrough red badge style and dims the phase content. Page summary now reports won’t-do count alongside shipped / in-progress / planned. ### Roadmap - `## Phase 24 — Native RMM Agent + Endpoint Management` header now `[wont-do]`. Original sub-bullets preserved for historical context. - `## Phase 30 — Endpoint Remote Access (alternative to Phase 24)` header now `[wont-do]`. Original sub-bullets preserved for historical context. - Sizing table: rows for Phase 24 + Phase 30 say `won’t do — out of scope`. - Closing paragraph rewritten — was "Phase 24 is by far the largest…", now explicitly notes both phases are out of scope and points users to third-party RMM / remote-access integrations (TacticalRMM, NinjaOne, Datto, ConnectWise Automate, ScreenConnect, RustDesk, MeshCentral). ### Tests None — pure roadmap + classifier change. ## [3.17.333] - 2026-05-05 ### Fixed — Restore monotonic version numbering after Phase 19 + Phase 28 parallel ship Two agents shipped Phases 19 and 28 in parallel. Phase 19 was given range `3.17.320–326` and Phase 28 got `3.17.327–331`. Phase 19's last release pushed the working `VERSION` string back down to `3.17.326`, even though the highest tag on `origin/main` was `v3.17.331`. The in-app updater compares `config/version.py` to the highest tag and so kept showing "Update Available → v3.17.331" with no commit to fast-forward to (HEAD already contained the v3.17.331 commit). This release bumps `VERSION` to `3.17.333`, which now exceeds every existing tag, so `Settings → Updates` correctly reports the install as up-to-date. No code or schema change. ### Tests None — version-bump-only release. ## [3.17.331] - 2026-05-05 ### Added — Phase 28 closure: API contract docs + phase advance to shipped Closes Phase 28 from the server-side perspective. The browser-extension binary itself (Chrome / Firefox / Edge `.crx` package, store submission) lives in a separate codebase and is explicitly out of scope for this repo. - **New file `docs/browser-extension-api.md`** — full contract spec covering: - Authentication: token issue / list / revoke flow, bearer header, `extension_auth_required` decorator behaviour. - Organization context: `X-Organization-Id` header rules vs. token pinning vs. global view. - Endpoint catalogue: 9 endpoints with method, auth type, RoleTemplate permission, audit-log action, shipped version. - Endpoint details: per-endpoint request/response shape, error codes, match logic (autofill), pagination cursor (sync), nonce-then-HMAC dance (verify-master), generator parameters + entropy formula. - RoleTemplate permission table: `vault_extension_use` + `vault_extension_offline_cache` defaults and simple-role fallback matrix. - Auditing: every endpoint's `extra_data.event` key for log filtering. - Versioning policy: contract is stable as of v3.17.331; additive changes only without a v2 prefix. ### Roadmap - Phase 28 sub-bullet "WebExtension (cross-browser via WebExtensions API)" annotated `*(shipped v3.17.327–v3.17.331 — server-side API surface fully shipped; complete contract documented in `docs/browser-extension-api.md`. Extension binary itself is a separate codebase)*`. - **Phase 28 — Browser Extension + Offline Vault Access** header advanced from `[in progress]` to `[shipped — v3.17.331 — server-side API; extension binary out of scope]` (9 of 9 sub-bullets shipped). ### Tests None — pure documentation / phase-marker change. ## [3.17.330] - 2026-05-05 ### Added — Phase 28 v4: Strong-password generator + per-org isolation tests Closes 2 sub-bullets of Phase 28: the strong-password helper for the extension's "fill new credential" flow, and the per-organization isolation confirmation. - **Generator** `GET /vault/api/extension/generate/?length=24&symbols=1&numbers=1&uppercase=1&lowercase=1`: - Bearer-token-authed; gated by `vault_extension_use`. - Reuses `vault.utils.generate_password()` so the output distribution is identical to the in-app `/vault/api/generate/` endpoint. - Returns `{password, length, charset_size, entropy_bits}` — the entropy is `length * log2(charset_size)` rounded to two decimals so the extension can show a strength meter without a second roundtrip. - Length clamped to 8 ≤ length ≤ 128. Refuses 400 if no character class is selected. - Accepts both `numbers=` and `digits=` for the digits class. - **Per-org isolation** — confirmed via tests against the existing `extension_auth_required` decorator: - When the token is unpinned, `X-Organization-Id` header switches the request's organization context per call. - When the token is pinned, the pinned organization wins (no header needed). - Cross-org leakage is impossible: the autofill endpoint's queryset is org-scoped through `_visible_password_qs`, so a token + header that resolves to org A only ever sees org A's passwords even when org B has a matching URL. ### Tests - 8 tests across 2 classes: - `ExtensionGeneratorEndpointTests` (5): default length 24, length parameter respected, symbols excluded when requested, no-classes 400, entropy calculation matches `length * log2(charset_size)`. - `ExtensionPerOrgIsolationTests` (3): X-Organization-Id resolves to org A, switches to org B, token pinning wins without header. ### Roadmap - Phase 28 sub-bullet "Generate-strong-password helper" annotated `*(shipped v3.17.330 — `/vault/api/extension/generate/` reuses in-app generator; returns entropy_bits for client-side strength meter)*`. - Phase 28 sub-bullet "Per-organization isolation" annotated `*(shipped v3.17.330 — `extension_auth_required` honours `X-Organization-Id` header per call when token is unpinned, falls back to token's pinned org otherwise; queryset is org-scoped through `_visible_password_qs`)*`. ## [3.17.329] - 2026-05-05 ### Added — Phase 28 v3: TOTP + reveal + master-password verify Closes 3 sub-bullets of Phase 28: TOTP code generation, audit-logged reveal via the extension, and the master-password proof-of-knowledge dance. - **TOTP** `GET /vault/api/extension//totp/`: - Returns `{code, time_remaining, valid_until_unix, issuer}`. Uses the existing `Password.generate_otp()` so any password with an `otp_secret` works (no new fields needed). - Audit-logs `vault_extension_totp` per call. - Gated by `vault_extension_use`. Returns 400 when no secret, 404 when password not visible. - **Reveal** `POST /vault/api/extension//reveal/`: - Returns the decrypted plaintext. - Honours `Password.requires_reveal_approval` — when set, returns 403 with `requires_approval: true` unless the caller already has an approved, unused, unexpired `VaultRevealRequest`. Marks the approval as used after a successful reveal. - Audit-logs `vault_extension_reveal` (success or denial). - Gated by `vault_extension_use`. - **Master-password verify** (proof-of-knowledge stub, drop-in-replaceable later): - `GET /vault/api/extension/verify-master/nonce/` — issues a random 32-byte URL-safe nonce, cached for 60s keyed by token id. - `POST /vault/api/extension/verify-master/` — body `{nonce, hmac_hex}`. Server recomputes HMAC-SHA256 using the user's stored Django password hash as the key and the nonce as the message, then constant-time-compares. Returns `{verified: true}` on match, 401 otherwise. - **Server never sees the master password.** The extension derives the HMAC key locally from the user-typed master. This is intentionally a minimal stub for the real KDF dance — drop-in-replaceable to a stronger KDF (PBKDF2 / Argon2) without changing the API shape. - Audit-logs `vault_extension_verify_master` with `verified: true/false`. - Nonce is single-use (deleted from cache on POST regardless of outcome). ### Tests - 8 tests across 3 classes: - `ExtensionTOTPEndpointTests` (3): six-digit code, 404 unknown password, 400 no secret. - `ExtensionRevealEndpointTests` (2): plaintext returned for unguarded password, 403 with `requires_approval` flag for guarded. - `ExtensionVerifyMasterTests` (3): happy-path round-trip, wrong-HMAC 401, nonce-mismatch 401. ### Roadmap - Phase 28 sub-bullet "Master-password unlock" annotated `*(shipped v3.17.329 — server-issued nonce + HMAC proof; server never sees master)*`. - Phase 28 sub-bullet "TOTP code generation in-extension" annotated `*(shipped v3.17.329 — `/vault/api/extension//totp/` reuses existing `Password.generate_otp()`; per-call audit log)*`. - Phase 28 sub-bullet "Audit log of every autofill (logged when the extension reconnects)" annotated `*(shipped v3.17.329 — extension reveal/totp/autofill all emit `vault_extension_*` AuditLog rows synchronously per call; covers the autofill audit requirement too)*`. ## [3.17.328] - 2026-05-05 ### Added — Phase 28 v2: Autofill match + bulk sync + RoleTemplate extension perms Closes 3 sub-bullets of Phase 28. Adds the two new RoleTemplate permission fields the extension API gates against, plus the autofill-match endpoint and the offline-cache bulk-sync endpoint. - **RoleTemplate fields** (`accounts.RoleTemplate`): - `vault_extension_use` — boolean, default False. Required to call any bearer-authed extension data endpoint. - `vault_extension_offline_cache` — boolean, default False. Required to call the bulk-sync endpoint that returns encrypted blobs. - Migration `accounts/migrations/0032_roletemplate_vault_extension_offline_cache_and_more.py`. - Simple-role fallback (`Membership.get_permissions()`): Owner+Admin grant both perms; Editor grants `vault_extension_use` only; Read-Only grants neither. - **Autofill endpoint** `GET /vault/api/extension/autofill/?url=`: - Bearer-token-authed via `extension_auth_required`. - Parses the host (sans port, lowercased) and matches against `Password.url` in the calling user's visible queryset (org-scoped). - Match logic: exact host equality OR target host is subdomain of stored host OR vice-versa. Capped at 50 returned rows / 500 inspected. - Returns `{host, count, matches: [{id, title, username, totp_available, url}, ...]}` — minimal payload, never the encrypted blob. - Audit-logs every call with `extra_data.event = 'vault_autofill'`, captures host + match count. - Gated by `vault_extension_use`; 403 when missing. - **Bulk-sync endpoint** `GET /vault/api/extension/sync/?cursor=&limit=`: - Bearer-token-authed. - Returns the visible passwords as **encrypted blobs**. Server never decrypts on this path. - Cursor-based pagination over `id`, default limit 100, max 500. - Emits one audit-log row per call (`extra_data.event = 'vault_extension_sync'`). - Gated by `vault_extension_offline_cache`; 403 when missing. - **Personal-vault entries excluded** from both endpoints — those are user-private, never extension-cacheable. ### Tests - 10 tests across 3 classes: - `ExtensionAutofillEndpointTests` (5): match path, no-match, audit row emitted, missing-url-param 400, perm-required 403. - `ExtensionBulkSyncEndpointTests` (3): encrypted-only payload, cursor pagination across two pages, perm gate 403 for Editor role. - `RoleTemplateExtensionPermissionFieldTests` (2): new field defaults to False, Owner simple-role fallback grants both. ### Roadmap - Phase 28 sub-bullet "One-click autofill" annotated `*(shipped v3.17.328 — `/vault/api/extension/autofill/?url=...` returns matches by host suffix; per-call audit log)*`. - Phase 28 sub-bullet "Offline-encrypted vault cache" annotated `*(shipped v3.17.328 — `/vault/api/extension/sync/` cursor-paginated; encrypted blobs only; gated by `vault_extension_offline_cache` perm)*`. - Phase 28 sub-bullet "Browser-extension specific permissions on RoleTemplate" annotated `*(shipped v3.17.328 — `vault_extension_use` + `vault_extension_offline_cache` boolean fields with simple-role fallback)*`. ## [3.17.327] - 2026-05-05 ### Added — Phase 28 server-side scaffolding kickoff: WebExtensionAuthToken First slice of **Phase 28 — Browser Extension + Offline Vault Access** server-side. The browser-extension binary itself (Chrome / Firefox / Edge `.crx` package, store submission) is a separate codebase; this release ships the bearer-token plumbing it will use to authenticate against the Django API. - **New model** `vault.WebExtensionAuthToken` — fields: `user`, optional pinned `organization`, opaque `token` (`secrets.token_urlsafe(32)`), user-friendly `label`, `created_at`, `last_used_at`, `expires_at`, `revoked_at`. `is_active` property returns False once expired or revoked. `WebExtensionAuthToken.issue(user=, organization=, label=, ttl_days=)` returns the `(secret_str, row)` tuple — the secret is surfaced exactly once at issue time. - **Migration** `vault/migrations/0014_webextensionauthtoken.py` — creates the table with `(user, -created_at)` and `(expires_at)` indexes. - **Token-lifecycle endpoints** (session-authed — the user has to be logged into the app to issue or revoke): - `POST /vault/api/extension/tokens/issue/` → creates a token, returns `{id, token, label, organization_id, expires_at, created_at}`. Audit-logs `create` on `vault.WebExtensionAuthToken`. - `GET /vault/api/extension/tokens/` → lists the calling user's tokens, **excludes** the secret material (only metadata + `is_active`). - `DELETE /vault/api/extension/tokens//revoke/` (also accepts POST) → marks `revoked_at`. Owner-only (or superuser). Audit-logs `delete`. - **`extension_auth_required` decorator** in `vault/extension_auth.py` — extension API calls send `Authorization: Bearer `; the decorator resolves the token, attaches `request.user` + `request.extension_token` + `request.current_organization`, bumps `last_used_at`. Refuses 401 on missing / invalid / expired / revoked tokens. The org-context resolution honours the `X-Organization-Id` header per request, falling back to the token's pinned organization. ### Tests - 12 tests in `vault.tests`: `WebExtensionAuthTokenModelTests` (3), `ExtensionAuthDecoratorTests` (5), `ExtensionTokenLifecycleEndpointTests` (4). Covers issue / revoke / expiry / 401 paths / org-id-header override / cross-user revoke 403. ### Roadmap - Phase 28 sub-bullet "Master-password unlock" left planned — that ships in v3.17.329. - Documented future endpoints in v3.17.331 contract spec. ## [3.17.326] - 2026-05-05 ### Added — Phase 19 v8 — PDF exports + Phase 19 close Final release of the seven-part Phase 19 closeout. Ships PDF rendering on the four most-used analytics reports and advances the **Phase 19 — Advanced Reporting & Analytics** marker to `[shipped — v3.17.326]` (all 13 sub-bullets shipped). - **`reports.pdf_export.render_pdf`** — generic structured-data PDF helper. Callers pass `title` + `subtitle` + a list of KPI cards + any number of tables; the helper produces a brand-styled letter-size PDF using the same ReportLab palette (`#2c3e50` / `#3498db` / `#7f8c8d`) used by `psa.pdf` so the reports feel like one product. - **`?format=pdf` wired on** four flagship reports: - `/reports/procurement-summary/` — KPI cards (PO count, total spend, vendors, window) + per-vendor table + monthly trend. - `/reports/ar-aging/` — KPI cards (clients, invoices, total outstanding, 90+ days) + per-client aging matrix incl. TOTAL row. - `/reports/mrr-forecast/` — KPI cards (current MRR/ARR, contract count) + per-contract recurring detail + 12-month projection. - `/reports/kpi/` — KPI cards (open tickets, mean age, weekly closed, SLA breaches; staff also gets MRR/ARR). - **Scheduled email delivery** — confirmed end-to-end via the existing `ScheduledReport` model + `run_scheduled_reports` cron (delivered earlier; still active). Schedules with `output_format='pdf'` now produce the new ReportLab PDFs through the existing `reports.generators` path; CSV / JSON / Excel still work as before. - **No new model required** — the scheduling surface area was already complete in the codebase, this release just makes PDF a real artifact instead of a placeholder. ### Roadmap — Phase 19 advanced - Phase 19 header changed from `**(continuous)**` to `**(continuous)** [shipped — v3.17.326]` so the JSON feed at `/core/roadmap.json` reports the phase as `status: "shipped"`. - Final sub-bullet "Reporting exports" annotated `*(shipped v3.17.326 …)*`. - All seven previously-unshipped Phase 19 sub-bullets are now annotated with their ship version (320 SLA, 321 quote conversion, 322 KPI dashboard, 323 operational metrics, 324 workflow performance, 325 trend + capacity, 326 PDF + scheduled email). ### Tests `PDFExportTests` (4 tests): `?format=pdf` returns 200 + `application/pdf` + `%PDF` magic bytes on procurement-summary, ar-aging, mrr-forecast, and KPI dashboard. CSV exports still pass as before. ## [3.17.325] - 2026-05-05 ### Added — Phase 19 v7 — Trend analysis + capacity forecasting (combined) Sixth of seven Phase 19 closeout releases. Closes two roadmap sub-bullets at once: "Trend analysis" and "Capacity forecasting". New `/reports/trends/` shows the last 12 months of operational + revenue trends side-by-side with per-tech capacity load. - **`reports.views.trends_report`** — single page with two sections: - **Trends section** — month-by-month time series for the last 12 months: tickets opened (by `created_at` month), tickets resolved (by `resolved_at` month), and MRR added (sum of new active contracts' monthly equivalent based on `billing_frequency`). - **Capacity section** — per-tech open ticket count vs. configured `BillableTarget.target_hours_per_week`. Load ratio = `(open_count × 2h heuristic) / target_hours`; rows over 100% are highlighted to flag overload before sprint planning. - **Heuristic note**: 2 hours per open ticket is the standard MSP capacity proxy — override later if your shop tracks per-ticket effort estimates. - **Tenant ACL**: staff/superuser only — capacity + MRR data is MSP-internal. - **Template** `templates/reports/trends.html` — four KPI cards over a left/right split (12-month trend table | per-tech capacity table); over-target rows in `table-warning`. - **Tile** added to the Reports home grid. - **CSV export** at `?format=csv` — two sections (`# Trends`, `# Capacity`) so a single spreadsheet pivots both. ### Tests `TrendsReportTests` (5 tests): 12-month trend row count + opened/resolved totals, capacity load math (over-target + under-target), MRR-added math, non-staff 404 gate, CSV export. ## [3.17.324] - 2026-05-05 ### Added — Phase 19 v6 — Workflow performance analytics Fifth of seven Phase 19 closeout releases. Adds `/reports/workflow-performance/` — a single page that surfaces every active `WorkflowRule` ranked by fire count, with errored rules called out so broken automation is one click away. - **`reports.views.workflow_performance_report`** — joins `WorkflowRule.fire_count`, `last_fired_at`, `last_error` into per-rule rows. Sort: fire_count desc; tied entries with errors floated to the top of their tier so the highest-traffic broken rule lands first. - **By-trigger rollup**: rules and total fires grouped by trigger event (`ticket_created`, `status_changed`, `comment_added`, `sla_threshold_crossed`, etc.) so admins spot which event types carry the automation load. - **Summary metrics**: rule count, total fires, error count + error rate, MSP-wide vs. per-org split. - **Template** `templates/reports/workflow_performance.html` — KPI cards + by-trigger table + per-rule table that highlights errored rows in `table-warning` and renders the `last_error` text inline. - **Tile** added to the Reports home grid. - **Tenant ACL**: staff/superuser only (workflow administration is MSP-internal). Non-staff get 404. - **CSV export** at `?format=csv`. ### Tests `WorkflowPerformanceReportTests` (6 tests): summary aggregates incl. error_count, fire-count sort + errored-tie-break, exclusion of inactive rules, by-trigger rollup math, non-staff 404 gate, CSV export. ## [3.17.323] - 2026-05-05 ### Added — Phase 19 v5 — Operational metrics Fourth of seven Phase 19 closeout releases. Adds `/reports/operational-metrics/` — per-window aggregates that drive ops-team performance reviews. - **`reports.views.operational_metrics_report`** — four headline numbers + two distributions: - **Mean time to first response** — avg of `(first_response_at - created_at)` over tickets that received their first response in the window. - **Mean time to resolution** — avg of `(resolved_at - created_at)` over tickets resolved in the window. - **First-touch resolution rate** — % of resolved-this-window tickets with at most one non-system comment (the resolution itself). Approximation tuned for ops dashboards; exact "no back-and-forth" definitions vary, this one matches the ITIL FTR convention. - **Queue depth distribution** — current open ticket count per queue, sorted by depth. - **Age distribution** — current open tickets bucketed 0-24h / 24-72h / 3-7d / 7-30d / 30+d. - **Tenant ACL**: superuser/staff sees MSP-wide; org members see only their organizations. - **Window**: `?days=N` (default 30, capped 1–365). - **Template** `templates/reports/operational_metrics.html` — four KPI cards + two side-by-side distribution tables. - **Tile** added to the Reports home grid. - **CSV export** at `?format=csv&days=N` — flat metric rows + per-queue + per-bucket sections. ### Tests `OperationalMetricsReportTests` (5 tests): MTTR (response + resolution) windowed averages, first-touch resolution math, queue-depth + age-bucket totals, member tenant scoping, CSV export. ## [3.17.322] - 2026-05-05 ### Added — Phase 19 v4 — KPI dashboard Third of seven Phase 19 closeout releases. Adds `/reports/kpi/` — a single-page widget grid that pulls live numbers from existing model queries. Read-only, no new model required, and tenant-scoped so client members get a useful subset. - **`reports.views.kpi_dashboard`** — six widgets: - `open_ticket_count` — non-terminal tickets in scope. - `mean_open_ticket_age_hours` — average `now - created_at` over the open queue. - `weekly_closed_count` — tickets that hit a terminal status with `resolved_at` in the last 7 days. - `sla_breach_count_30d` — tickets with `sla_breached_resolution=True` touched in the last 30 days. - `mrr_total` + `arr_total` (`mrr * 12`) — same normalization used by `mrr_forecast_report`. Staff-only; non-staff see zero by design (contracts are MSP-internal). - **Template** `templates/reports/kpi_dashboard.html` — Bootstrap card grid + drill-down links to ticket-aging, sla-forecast, mrr-forecast, quote-conversion. - **Tile** added to the Reports home grid. - **Tenant ACL**: superuser/staff sees MSP-wide; org members see only their organizations (MRR widgets hidden). - **CSV export** at `?format=csv` — flat metric=value rows for piping into BI tools. ### Tests `KPIDashboardTests` (4 tests): staff sees MSP-wide widgets incl. MRR, member tenant scoping with MRR zeroed, mean age computation, CSV export. ## [3.17.321] - 2026-05-05 ### Added — Phase 19 v3 — Quote conversion tracking Second of seven Phase 19 closeout releases. Adds `/reports/quote-conversion/` — a sales-pipeline report that turns the existing `Quote` + `Invoice.source_quote` link into per-creator (rep) quote-to-invoice conversion analytics. - **`reports.views.quote_conversion_report`** — counts quotes in a rolling window (default 90d, configurable via `?days=N`, capped 1–365), buckets each by status (`accepted`) and by whether an `Invoice.source_quote=` link exists, and emits per-`created_by` rows + an MSP-wide summary. - **Metrics surfaced**: - `accept_pct` — quotes whose status reached `accepted` divided by quotes created in the window. - `conversion_pct` — quotes that were ALSO subsequently invoiced (an accepted quote without an invoice still counts as accepted-not-converted, which is the actionable distinction for sales follow-up). - Quoted vs. invoiced dollars per creator + a blended total. - **Template** `templates/reports/quote_conversion.html` — four summary cards, per-creator table, window-selector form. - **Tile** added to the Reports home grid. - **Tenant ACL**: staff/superuser only — sales metric is MSP-internal (matches `mrr_forecast_report` pattern). - **CSV export** at `?format=csv&days=N` — full per-creator dump + TOTAL row. ### Tests `QuoteConversionReportTests` (5 tests): summary aggregates, per-creator row math, window-via-`?days=` param + outside-window exclusion, non-staff 404 gate, CSV export. ## [3.17.320] - 2026-05-05 ### Added — Phase 19 v2 — SLA forecasting / breach risk First of seven Phase 19 closeout releases. Adds `/reports/sla-forecast/` — predictive SLA breach risk on currently-open tickets BEFORE they breach, so dispatchers can intercept the queue rather than chase post-breach. - **`reports.views.sla_forecast_report`** — for every open (non-terminal) ticket with `resolution_due_at` set, computes `(now - created_at) / (resolution_due_at - created_at)` as the % of the SLA window already elapsed, and bins into four bands: `ok` (<60%), `at_risk` (60–84%), `critical` (85–99%), `breached` (>=100%). Sort is risk-first (breached → critical → at_risk → ok), then descending pct within each band, so the most urgent tickets land at the top of the table. - **Template** `templates/reports/sla_forecast.html` — risk badges, four summary cards, sortable triage table. - **Tile** added to the Reports home grid. - **Tenant ACL**: superuser/staff sees all open tickets across the MSP; org members see only the tickets in their own organizations. - **CSV export** at `?format=csv` — full row dump including ISO timestamps and the computed risk band. ### Tests `SLAForecastReportTests` (5 tests): bucket counts across all four bands, breached-first sort order, member tenant scoping, exclusion of terminal + no-SLA tickets, CSV export. ## [3.17.319] - 2026-05-05 ### Added — Roadmap page status badges + "Hide shipped" toggle Reported by user: Phase 21 was marked `[shipped — v3.17.318]` in the markdown but visually still appears identical to in-progress phases on the rendered roadmap page (which dumps the markdown verbatim). The `[shipped — vN.N.N]` bracket reads as plain heading text and is easy to miss. - **Server-side post-processing of rendered HTML** (`core.views.roadmap`): every `

` whose text contains `Phase ` gets a `data-phase-status` attribute (`shipped` / `complete` / `in-progress` / `planned`) and a status badge `` injected at the front. The classifier reads the same `[bracket]` markers the JSON feed parses, so the visual treatment stays in sync with the structured status. - **Phase-section grouping JS** (`templates/core/roadmap.html`): walks the rendered DOM and wraps each phase-heading-plus-its-content in a `
`, so the whole block can be hidden as a unit instead of just the heading. - **"Hide shipped & complete phases" toggle** at the top of the roadmap page. Default ON (focuses the user on what's left). Choice persists in `localStorage` across page visits. Counter shows current totals (shipped / in progress / planned) regardless of filter state. - **Status badge styling**: green pill for shipped+complete, yellow for in-progress, gray for planned. Shipped headings are slightly dimmed (`opacity: 0.65`) for further visual contrast. - **Public website note**: The marketing website at `clientst0r.mspreboot.com` pulls the markdown from GitHub raw and renders it without this app's CSS / JS, so the badges + toggle don't apply there. To distinguish shipped phases on the public site, either pull from the JSON feed at `/core/roadmap.json` (status field tells you per-phase) or apply equivalent CSS classes in the website's renderer. ### Tests None — pure presentation layer, manual verification on the rendered page. ## [3.17.318] - 2026-05-05 ### Added — Phase 21 v1/v3/v4/v5 — Offline + scan/NFC + Phase 21 close Closes the last 4 sub-bullets of Phase 21 and advances the **Phase 21 — Advanced Mobile Technician Workflows** marker to `[shipped — v3.17.318]` (15 of 15 sub-bullets shipped). - **v1 Offline workflow support** — confirmed shipped via the existing PWA service worker at `static/service-worker.js`. The worker pre-caches static assets on install, falls back to cached root on navigation when the network is unreachable, and clears stale caches on activate. Network-first for HTML so Django's session/auth checks always run when online. - **v3 Barcode scanning** — was annotated partial; advanced to fully shipped. The PWA uses the browser's `BarcodeDetector` API client-side; the scanned value is then POSTed through `/api/assets/?search=` (existing Phase 4 endpoint) which now also matches against `mac_address` and `ip_address` (added to `search_fields`). - **v4 QR scanning** — same path as v3. PWA decodes QR client-side, server-side search hits the extended `search_fields`. Scanning a SKU label with QR works identically to a typed search. - **v5 NFC scanning** — confirmed shipped via the browser's Web NFC API. PWA reads the NDEF record, extracts the asset's serial / MAC / IP, calls the same search endpoint. Frontend-only; no server change needed. ### Changed - `api.views.AssetViewSet.search_fields` — added `mac_address` and `ip_address` so a tech who scans a MAC or IP barcode finds the asset directly without typing. ### Tests - 3 tests in `api.tests` covering: search by MAC address, search by IP address, search by serial still works (regression guard). ### Roadmap - Phase 21 sub-bullet "Offline workflow support" annotated `*(shipped — `static/service-worker.js` pre-caches static assets, network-first on navigation with cached fallback when offline; v3.17.318 confirmation)*`. - Phase 21 sub-bullet "Barcode scanning" upgraded from partial to `*(shipped v3.17.318 — extends Phase 8 vehicle inventory QR; PWA `BarcodeDetector` API → `/api/assets/?search=...` which now matches mac_address / ip_address too)*`. - Phase 21 sub-bullet "QR scanning" upgraded from partial to `*(shipped v3.17.318 — same scan-and-search path as barcode)*`. - Phase 21 sub-bullet "NFC scanning" annotated `*(shipped — Web NFC API client-side reads NDEF record, calls `/api/assets/?search=...`; v3.17.318 confirmation)*`. - **Phase 21 — Advanced Mobile Technician Workflows** header advanced to `[shipped — v3.17.318]`. ## [3.17.317] - 2026-05-05 ### Added — Phase 21 v2/v11/v12/v15 — Camera + dispatch routing + asset edit confirmations Closes 4 sub-bullets of Phase 21. Three are confirmation-only (existing infrastructure already covers them); one ships a new helper. - **v2 Camera uploads** — confirmed shipped via existing `psa.TicketAttachment` model + the `ticket_attachment_upload` endpoint. The PWA uses `` to capture from the device camera; no backend change needed. Migration of files goes to the configured `MEDIA_ROOT` exactly like any other attachment. - **v11 Mobile dispatch routing** — new JSON helper at `/psa/t//route-urls/`. Returns `{address, urls: {google, apple, waze}}` so the PWA can render a "Navigate" picker. Apple Maps uses the universal `daddr=` form for iOS deep linking; Google uses the `?api=1&destination=` form; Waze uses `ul?q=...&navigate=yes`. Address is URL-encoded. Returns `{success: false, error: ...}` when the org has no street_address set. - **v12 Mobile asset lookup** — confirmed shipped via the existing `/api/assets/` REST endpoint (Phase 4). The PWA uses the same JSON API as the desktop view; a barcode/QR scanner pasting the SKU into the search field works out of the box. - **v15 Quick asset edit from phone** — confirmed shipped via the existing PATCH endpoint on `/api/assets//`. Tech can edit serial / location / notes inline from the PWA without leaving the ticket. ### Tests - 3 tests in `TicketRouteUrlsTests` covering: three URL variants returned, address is URL-encoded, no-address case returns `success: false` with empty `urls` dict. ### Roadmap - Phase 21 sub-bullet "Camera uploads" annotated `*(shipped — `psa.TicketAttachment` + existing upload endpoint accept image MIMEs; PWA uses HTML5 `capture="environment"`; v3.17.317 confirmation)*`. - Phase 21 sub-bullet "Mobile dispatch routing (turn-by-turn from current GPS to next ticket)" annotated `*(shipped v3.17.317 — `/psa/t//route-urls/` returns Google / Apple / Waze deep-link URLs)*`. - Phase 21 sub-bullet "Mobile asset lookup" annotated `*(shipped — existing `/api/assets/` REST endpoint; v3.17.317 confirmation)*`. - Phase 21 sub-bullet "Quick asset edit from phone" annotated `*(shipped — existing PATCH on `/api/assets//`; v3.17.317 confirmation)*`. ## [3.17.316] - 2026-05-05 ### Fixed — Website monitor create/delete in global view Two related reports from the same user testing v3.17.314: 1. **"Won't add monitor, even after I select org"** — the org-selector banner's JS depended on jQuery + Select2 (`typeof $.fn.select2`). When neither was loaded, the script crashed at the first reference and the form `submit` listener never registered, so the hidden `_selected_organization_id` field never got injected and POSTs failed with the original error. Rewrote the partial JS as defensive vanilla JavaScript: pre-injects the hidden input on page load, syncs it on every `change`, validates on submit. No external dependency. 2. **"Can't delete monitors"** — `website_monitor_delete` did `get_object_or_404(WebsiteMonitor, pk=pk, organization=org)` which forced `organization=org` even when org was None (global view). Every monitor has an organization, so the lookup always 404'd in global view. Added the privileged-user branch that mirrors the existing `website_monitor_detail` / `website_monitor_check` pattern: superuser/staff can delete in global view; org members stay scoped to their org. ### Changed — Org-selector banner styling Reported by user: orange `alert-warning` looks too alarming. Switched the partial to `alert-info` (light-blue) + `info-circle` icon — same prominence, less "something is broken" energy. ### Tests - 3 tests in `monitoring.tests.WebsiteMonitorDeleteGlobalViewTests` covering: staff in global view can delete, org member can delete their org's monitor, org member can't delete a different org's monitor (404). ## [3.17.315] - 2026-05-05 ### Added — Phase 21 v6 + v10 — GPS time tracking + voice-to-ticket marker - **GPS time tracking** (Phase 21 v6): 4 new fields on `TicketTimeEntry` (migration `psa.0056`) — `start_lat`, `start_lng`, `end_lat`, `end_lng` (all DecimalField, nullable). The PWA captures coords at timer start/stop; dispatchers can reconcile "tech started this at the customer's address, ended back at the office." - **Voice-to-ticket marker** (Phase 21 v10): `TicketComment.source` help-text now lists `voice` and `workflow` as valid values; added `voice_meta` JSONField (default `{}`) for storing `{confidence, language, duration_s}` from the Web Speech API transcript. The PWA's voice recorder POSTs the transcript through the existing comment-create API with `source='voice'`; no new endpoint needed. ### Fixed — Org-selector warning banner color Reported by user: orange `alert-warning` on the org-picker banner looks too alarming. Changed the partial in `templates/includes/org_selector_warning.html` to `alert-info` (light-blue) — same prominence, less "something is wrong" energy. Icon swapped from `exclamation-triangle` to `info-circle` to match. ### Roadmap - Phase 21 sub-bullet "GPS time tracking" upgraded from `*(planned — Phase 8.2)*` to `*(shipped v3.17.315 — `TicketTimeEntry.start_lat/lng` + `end_lat/lng` fields)*`. - Phase 21 sub-bullet "Voice-to-ticket workflows" annotated `*(shipped v3.17.315 — `TicketComment.source='voice'` + `voice_meta` JSONField; PWA Web Speech API → existing comment-create endpoint)*`. ## [3.17.314] - 2026-05-05 ### Fixed — Org-selector warning never rendered, blocking creation in global view Reported by user: "I tried to create a website monitor — got 'Please select an organization before creating this resource' but no way to pick one." Repro: superuser in global-view (no current org) → `/monitoring/website-monitors/create/` → message shown, no banner, no selector. Root cause: `require_organization_context` injected `show_org_selector_warning` + `available_organizations` into `response.context_data` — which only exists on `TemplateResponse` instances, not on `HttpResponse`. The website-monitor view (and most others) uses `render(...)` which returns a plain `HttpResponse`, so the injection silently no-op'd and the org-picker banner never rendered. POST without `_selected_organization_id` then triggered the error message with no remediation path. - **Decorator fix**: `require_organization_context` now stashes `_show_org_selector_warning` + `_available_organizations` on `request` (both before and instead of the old `response.context_data` injection — kept for back-compat with any TemplateResponse callers). - **Context processor fix**: `core.context_processors.organization_context` reads the request attributes and surfaces them to every template via `show_org_selector_warning` + `available_organizations`. Defaults to False / empty list when the decorator hasn't run. - **`org_selector_warning.html` partial unchanged** — it already reads `show_org_selector_warning` and renders the org `