Sep 4 - Add link relavant issues function
This commit is contained in:
@@ -35,6 +35,7 @@
|
|||||||
25. [Health Dashboard](#25-health-dashboard-health-on-panel)
|
25. [Health Dashboard](#25-health-dashboard-health-on-panel)
|
||||||
26. [Coding Rules for AI Assistants](#26-coding-rules-for-ai-assistants)
|
26. [Coding Rules for AI Assistants](#26-coding-rules-for-ai-assistants)
|
||||||
29. [Photo Object Storage (R2)](#29-photo-object-storage-r2)
|
29. [Photo Object Storage (R2)](#29-photo-object-storage-r2)
|
||||||
|
30. [Database Health Check](#30-database-health-check-scriptsdb_healthpy)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -224,6 +225,8 @@ lt_janitorial_quality_control/
|
|||||||
| `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. |
|
| `REDIS_URL` | Optional. When set, Flask-Limiter uses Redis for shared rate-limit counters across Gunicorn workers. |
|
||||||
| `GROQ_API_KEY` | Optional. When set, enables the AI chatbot at `/support/chat`. Absent → chat input disabled; customers see a "Submit to Support" fallback only. |
|
| `GROQ_API_KEY` | Optional. When set, enables the AI chatbot at `/support/chat`. Absent → chat input disabled; customers see a "Submit to Support" fallback only. |
|
||||||
| `GROQ_MODEL` | Optional. Groq model ID. Defaults to `llama-3.3-70b-versatile`. |
|
| `GROQ_MODEL` | Optional. Groq model ID. Defaults to `llama-3.3-70b-versatile`. |
|
||||||
|
| `DB_POOL_RECYCLE` | Optional, default `1800` (seconds). Retires a pooled connection on the **default bind** after this long. **Must stay below the server's `wait_timeout`** or MySQL closes the socket first and the next request gets `OperationalError 2006`. `scripts/db_health.py` cross-checks the two. Per-tenant engines use `TENANT_ENGINE_POOL_RECYCLE` instead. |
|
||||||
|
| `DB_POOL_SIZE` / `DB_MAX_OVERFLOW` | Optional, default `5` / `5`. Per-**worker** pool on the default bind. In MT the real ceiling is `workers x [ (default pool) + TENANT_ENGINE_CACHE_MAX x (tenant pool) ]` — the library defaults (5+10) alone put a 9-worker box at 135 against a `max_connections` of 151, before any tenant engine is counted. |
|
||||||
| `MULTI_TENANT_ENABLED` | `false` by default. Set `true` to activate Host→tenant routing. Requires all control-plane vars below. |
|
| `MULTI_TENANT_ENABLED` | `false` by default. Set `true` to activate Host→tenant routing. Requires all control-plane vars below. |
|
||||||
| `CONTROL_DATABASE_URL` | Control-plane MySQL URI, e.g. `mysql+pymysql://jqc_control:pw@127.0.0.1/jqc_control`. Required when `MULTI_TENANT_ENABLED=true`. |
|
| `CONTROL_DATABASE_URL` | Control-plane MySQL URI, e.g. `mysql+pymysql://jqc_control:pw@127.0.0.1/jqc_control`. Required when `MULTI_TENANT_ENABLED=true`. |
|
||||||
| `CONTROL_FERNET_KEY` | Fernet key for encrypting tenant DB passwords. Generate once; store in `/etc/jqc/control.env`. |
|
| `CONTROL_FERNET_KEY` | Fernet key for encrypting tenant DB passwords. Generate once; store in `/etc/jqc/control.env`. |
|
||||||
@@ -363,6 +366,28 @@ notifications: id, user_id, title, body, link, is_read, created_at, issue_id,
|
|||||||
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
|
notification_preferences: id, user_id, event_type, email_enabled, digest_mode, digest_frequency
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### IssueLink (phase57)
|
||||||
|
|
||||||
|
```
|
||||||
|
issue_links: id, issue_id (FK→issues CASCADE, indexed),
|
||||||
|
linked_issue_id (FK→issues CASCADE, indexed),
|
||||||
|
link_type ENUM('duplicate','related') NOT NULL DEFAULT 'related',
|
||||||
|
created_by (FK→users SET NULL), created_at
|
||||||
|
UniqueConstraint(issue_id, linked_issue_id) — uq_issue_link
|
||||||
|
```
|
||||||
|
|
||||||
|
**Connects a duplicate to its original, or two issues about the same thing**, so whoever picks one up can reach the other.
|
||||||
|
|
||||||
|
**One row is stored per pair and shown on BOTH issues.** The stored direction carries meaning for `duplicate` — `issue_id` is a duplicate *of* `linked_issue_id` — so the same row reads differently at each end: "Duplicate of #B" on one, "Duplicated by #A" on the other. `related` is symmetric and reads "Related to" from either side. `IssueLink.LABELS` is keyed `(link_type, is_source)` and is the only place that wording lives; `label_for(viewing_issue_id)` / `other_issue(viewing_issue_id)` resolve a row against whichever issue is on screen.
|
||||||
|
|
||||||
|
Storing one row rather than a mirrored pair keeps the direction unambiguous and makes unlinking a single delete. The cost: **uniqueness cannot be expressed by the UniqueConstraint alone.** `(A,B)` and `(B,A)` are distinct rows to MySQL but the same link to a person, so **`IssueLink.exists_between(a, b)` is the only correct duplicate check** — it looks both ways. The constraint catches the exact-duplicate row; `exists_between()` catches the reverse.
|
||||||
|
|
||||||
|
**A link is PURELY NAVIGATIONAL.** Marking a duplicate does **not** touch either issue's status, `resolved_at`, SLA, assignee or followers, and fires no notification. Closing the duplicate stays a separate, deliberate action. Do not add side effects here without saying so in the UI — the link control reads as navigation, and a status write from it would be invisible.
|
||||||
|
|
||||||
|
**Two FKs from one table to `issues`, so both relationships pin `foreign_keys`** — the same failure mode as phase56's third `inspections`→`users` FK (§17), which raises on first ORM *use*, not at import. `Issue.links_from` / `Issue.links_to` are the two storage directions; **`Issue.all_links()` merges them** into the single list a person actually sees. Both relationships cascade `all, delete-orphan` (and both FKs are `ON DELETE CASCADE`), so deleting an issue takes its links from *either* end — a surviving link would render a dead row on the other issue's page.
|
||||||
|
|
||||||
|
**Nothing here is tenant-aware, deliberately.** The table lives in the tenant DB and routes through `RoutingSession`, so a link can only reach an issue in the same tenant. Scope *within* a tenant is the caller's job — **see rule 110.**
|
||||||
|
|
||||||
### IssueComment
|
### IssueComment
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -565,7 +590,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
|
|||||||
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
|
||||||
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
|
||||||
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
|
||||||
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign |
|
| `issues` | `/issues` | list, view, create, update, verify, comment, follow/unfollow, verification queue, bulk-verify, delete, quick-assign, **issue links** (`POST /<id>/links` add, `POST /<id>/links/<link_id>/delete` remove, `GET /<id>/link-search` scoped JSON picker — phase57) |
|
||||||
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron), trial-reminders (cron), dunning-reminders (cron) |
|
| `notifications` | `/notifications` | list, mark-read, preferences, send-digest (cron), check-sla (cron), cleanup-tokens (cron), trial-reminders (cron), dunning-reminders (cron) |
|
||||||
| `audit` | `/audit` | list (admin only), view, purge |
|
| `audit` | `/audit` | list (admin only), view, purge |
|
||||||
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
|
| `reports` | `/reports` | index, facility report, scorecard, CSV/PDF/Excel export, issues-aging, sla-compliance, followup-closure, facility summary PDF |
|
||||||
@@ -843,7 +868,7 @@ limiter = Limiter(
|
|||||||
|
|
||||||
## 17. Alembic Migration Chain
|
## 17. Alembic Migration Chain
|
||||||
|
|
||||||
**Current HEAD:** `phase56_followup_assignee`.
|
**Current HEAD:** `phase57_issue_links`.
|
||||||
|
|
||||||
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
|
||||||
|
|
||||||
@@ -885,7 +910,7 @@ limiter = Limiter(
|
|||||||
→ phase50_sched_acknowledged → phase51_external_inspector
|
→ phase50_sched_acknowledged → phase51_external_inspector
|
||||||
→ phase52_user_ui_theme → phase53_knowledge_sort_order
|
→ phase52_user_ui_theme → phase53_knowledge_sort_order
|
||||||
→ phase54_user_notif_matrix → phase55_template_contracts
|
→ phase54_user_notif_matrix → phase55_template_contracts
|
||||||
→ phase56_followup_assignee ← HEAD
|
→ phase56_followup_assignee → phase57_issue_links ← HEAD
|
||||||
```
|
```
|
||||||
|
|
||||||
`phase54` / `phase55` port the ST August-2026 work (ST calls them phase51 /
|
`phase54` / `phase55` port the ST August-2026 work (ST calls them phase51 /
|
||||||
@@ -923,6 +948,33 @@ every request 500s. Column + constraint checks — safe to re-run.
|
|||||||
|
|
||||||
ST calls this phase53; MT's chain was already past that number. Match by NAME.
|
ST calls this phase53; MT's chain was already past that number. Match by NAME.
|
||||||
|
|
||||||
|
#### phase57 — link related and duplicate issues
|
||||||
|
|
||||||
|
Creates `issue_links` (§5 `IssueLink`). **Purely additive** — nothing reads the
|
||||||
|
table until a person creates a link, so an empty table is exactly today's
|
||||||
|
behaviour and there is nothing to backfill.
|
||||||
|
|
||||||
|
Both issue FKs are `ON DELETE CASCADE`, so a direct SQL delete of an issue
|
||||||
|
cannot leave a link pointing at a row that no longer exists (the ORM cascade on
|
||||||
|
`links_from`/`links_to` covers the application path). The index names
|
||||||
|
deliberately match what SQLAlchemy's `index=True` generates —
|
||||||
|
`ix_issue_links_issue_id`, `ix_issue_links_linked_issue_id` — which matters more
|
||||||
|
in MT than in ST: a tenant **bootstrapped** from the baseline and one
|
||||||
|
**upgraded** through the chain must end up with the same schema, down to the
|
||||||
|
index names.
|
||||||
|
|
||||||
|
Table-existence check — safe to re-run, as every migration from phase33 on must
|
||||||
|
be. `downgrade()` drops the table, discarding every link; no issue is affected,
|
||||||
|
since a link never held state belonging to one.
|
||||||
|
|
||||||
|
ST calls this phase54; MT's chain was already past that number. Match by NAME.
|
||||||
|
|
||||||
|
**Deploy order (tenant DBs):**
|
||||||
|
```bash
|
||||||
|
python -m control.tenant_migrate upgrade --tenant all
|
||||||
|
sudo systemctl restart gunicorn
|
||||||
|
```
|
||||||
|
|
||||||
**Deploy order (tenant DBs):**
|
**Deploy order (tenant DBs):**
|
||||||
```bash
|
```bash
|
||||||
python -m control.tenant_migrate upgrade --tenant all
|
python -m control.tenant_migrate upgrade --tenant all
|
||||||
@@ -1525,6 +1577,9 @@ set -a; . /etc/jqc/control.env; set +a
|
|||||||
| 107 | **`viewer_is_our_staff` in `issues/view.html` is an explicit role ALLOWLIST, and `external_inspector` is absent on purpose** | `not current_user.is_customer_account` fails OPEN — a missing attribute yields Jinja `Undefined`, `not Undefined` is true, and the internal-process chrome renders for exactly the accounts it must be hidden from. This is not a rule-87 violation: rule 87 governs capability/scoping, where a Customer Inspector must behave like our inspector; this asks "does this person work for us?", the one place the two genuinely differ. |
|
| 107 | **`viewer_is_our_staff` in `issues/view.html` is an explicit role ALLOWLIST, and `external_inspector` is absent on purpose** | `not current_user.is_customer_account` fails OPEN — a missing attribute yields Jinja `Undefined`, `not Undefined` is true, and the internal-process chrome renders for exactly the accounts it must be hidden from. This is not a rule-87 violation: rule 87 governs capability/scoping, where a Customer Inspector must behave like our inspector; this asks "does this person work for us?", the one place the two genuinely differ. |
|
||||||
| 108 | **A follow-up has exactly ONE owner: use `follow_up_owner` (row) / `follow_up_owned_by()` (query) — never re-derive it** | Assignee when set, original inspector otherwise. The API's two arms must be mutually exclusive (`follow_up_assigned_to == me` OR `assigned_to IS NULL AND inspector_id == me`); drop the `IS NULL` and two people turn up for the same re-inspection. The authorship filter must be DEFERRED when `follow_up_required=true` is requested, or the rows the assignee needs are hidden before the ownership test runs. |
|
| 108 | **A follow-up has exactly ONE owner: use `follow_up_owner` (row) / `follow_up_owned_by()` (query) — never re-derive it** | Assignee when set, original inspector otherwise. The API's two arms must be mutually exclusive (`follow_up_assigned_to == me` OR `assigned_to IS NULL AND inspector_id == me`); drop the `IS NULL` and two people turn up for the same re-inspection. The authorship filter must be DEFERRED when `follow_up_required=true` is requested, or the rows the assignee needs are hidden before the ownership test runs. |
|
||||||
| 109 | **Inspector READ access is facility scope; WRITE access is authorship** | `index()` lists by facility (rule 58), so `view()`/`export_pdf()` must too — scoping reads by authorship made the list offer rows that said "Access denied" on click, and locked the follow-up assignee out of the parent inspection. `execute`, `save_draft_ajax`, `upload_photo_ajax` and `flag_issue` keep the authorship check: readable is not editable. |
|
| 109 | **Inspector READ access is facility scope; WRITE access is authorship** | `index()` lists by facility (rule 58), so `view()`/`export_pdf()` must too — scoping reads by authorship made the list offer rows that said "Access denied" on click, and locked the follow-up assignee out of the parent inspection. `execute`, `save_draft_ajax`, `upload_photo_ajax` and `flag_issue` keep the authorship check: readable is not editable. |
|
||||||
|
| 110 | **An issue link is a pointer to another issue — filter it by scope on ALL THREE surfaces** | A link exposes the far issue's id, description, facility and status, so an unfiltered panel lets a customer read an issue at a facility they hold no assignment to, simply because one of our staff linked it. Three surfaces have to hold the line and only one is a real boundary: `_readable_links()` filters what the panel RENDERS, `link_search()` scopes what the picker FINDS, and `add_link()` re-checks on POST — the search is a convenience and must never be trusted as the gate. All three resolve scope through `_viewer_facility_scope()` / `_issue_in_scope()`, the same pair `issues.view()` now uses, so the panel cannot end up more permissive than the page it sits on. `_issue_in_scope` takes a resolved scope rather than a user, so filtering a list costs one assignment query, not one per row. A link to an issue outside your scope reports "not found", never "access denied" — whether another customer's issue exists is not something the link box should confirm. Cross-TENANT isolation is a different layer and is already handled: `RoutingSession` has bound the session to `g.tenant`'s database, so an id from another tenant does not resolve at all. |
|
||||||
|
| 111 | **The MT connection ceiling is `workers x [ default pool + cache_cap x tenant pool ]` — never just the default pool** | `config.SQLALCHEMY_ENGINE_OPTIONS` governs only the default bind; in MT nearly every request runs on a per-tenant engine from `app/tenancy/engine_cache.py` with its own `TENANT_ENGINE_*` pool, and each worker caches up to `TENANT_ENGINE_CACHE_MAX` of them. Sizing against the default pool alone is how a box reaches "Too many connections" while the health check reports headroom. `scripts/db_health.py` does the full arithmetic; the cache cap is the multiplier worth lowering first. Both pools set `pool_pre_ping` — without it an idle overnight surfaces as `OperationalError 2006` on the next request. |
|
||||||
|
| 112 | **The SLA cron narrows to candidates in SQL; the prefilter is a conservative SUPERSET, never an equality** | `send_sla_alerts()` runs every 30 minutes **per tenant**, so reading the whole open-issue backlog to decide in Python multiplied by the tenant count. The three filters each mirror a `continue` in the loop, and one of them — `reported_at IS NOT NULL` — is a correctness fix, not a speed one: `sla_status()` raises `TypeError` on a NULL and one such row aborted that tenant's entire run. The prefilter deliberately does NOT replicate the "already notified at_risk and still only at_risk" skip (that would mean writing the per-severity deadline arithmetic a second time, in SQL); the loop still applies it, so extra rows are read but no extra notification is sent. Pinned by `tests/test_sla_candidate_query.py` — a change that makes the prefilter narrower than the loop is a silently unsent alert. |
|
||||||
| 98 | **Reports R1 + R2 contract cascade is client-side only — facility_id is the sole DB filter** | The Contract dropdown in `reports/issues_aging.html` and `reports/sla_compliance.html` has no `name` attribute and is never submitted. It exists only to narrow the Facility `<select>` in the browser via `GET /inspections/facilities_for_project/<id>`. The routes receive and filter on `facility_id`; `contract_id` plays no role server-side. Do not add server-side `contract_id` filtering to these routes — it would duplicate what `facility_id` already provides. |
|
| 98 | **Reports R1 + R2 contract cascade is client-side only — facility_id is the sole DB filter** | The Contract dropdown in `reports/issues_aging.html` and `reports/sla_compliance.html` has no `name` attribute and is never submitted. It exists only to narrow the Facility `<select>` in the browser via `GET /inspections/facilities_for_project/<id>`. The routes receive and filter on `facility_id`; `contract_id` plays no role server-side. Do not add server-side `contract_id` filtering to these routes — it would duplicate what `facility_id` already provides. |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -2247,3 +2302,62 @@ local directory carries none. Both need `CONTROL_DATABASE_URL` +
|
|||||||
|
|
||||||
**Rollback is one env var:** `STORAGE_BACKEND=local` + restart. The sync never
|
**Rollback is one env var:** `STORAGE_BACKEND=local` + restart. The sync never
|
||||||
deletes local files, so the old tree is intact indefinitely.
|
deletes local files, so the old tree is intact indefinitely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 30. Database Health Check (`scripts/db_health.py`)
|
||||||
|
|
||||||
|
A standalone operations tool for the MySQL side. It imports the app factory for
|
||||||
|
config and nothing else — no request layer, no uploads tree — and the plain
|
||||||
|
invocation is **strictly read-only** (INFORMATION_SCHEMA / SHOW / EXPLAIN only).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a; . /etc/jqc/control.env; set +a # needed for any --tenant but 'default'
|
||||||
|
|
||||||
|
python scripts/db_health.py # default bind, read-only
|
||||||
|
python scripts/db_health.py --tenant all # every tenant schema
|
||||||
|
python scripts/db_health.py --tenant acme --json /tmp/db.json
|
||||||
|
python scripts/db_health.py --tenant all --analyze # refresh optimizer stats (safe)
|
||||||
|
python scripts/db_health.py --tenant acme --optimize --yes # rebuild (LOCKS)
|
||||||
|
python scripts/db_health.py --tenant all \
|
||||||
|
--emit-migration migrations/versions/phase58_perf_indexes.py \
|
||||||
|
--revision phase58_perf_indexes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Every tenant has its own database built from the same chain, so a schema check
|
||||||
|
is per tenant, not per deployment.** `--tenant` takes `default` (the
|
||||||
|
`SQLALCHEMY_DATABASE_URI` bind — the whole story in single-tenant mode, and the
|
||||||
|
fallback bind in MT), a slug, or `all`. Tenant modes resolve through the control
|
||||||
|
plane exactly as `control/backup.py` does, and open each tenant with a
|
||||||
|
`NullPool` engine: a one-shot CLI must not open a full pool per tenant against
|
||||||
|
the very `max_connections` it is checking.
|
||||||
|
|
||||||
|
The **server-level** checks (`max_connections`, `wait_timeout`,
|
||||||
|
`innodb_buffer_pool_size`, slow-query log, STRICT mode) describe the MySQL
|
||||||
|
instance, not a schema, so they run **once** against the first database opened.
|
||||||
|
Everything schema-shaped — hygiene, footprint, missing/redundant indexes,
|
||||||
|
unindexed FKs, EXPLAIN — runs per tenant, and each finding is tagged with the
|
||||||
|
tenant it came from.
|
||||||
|
|
||||||
|
**It never DROPs anything.** Redundant indexes are reported with the SQL to run
|
||||||
|
by hand, because "unused" is a judgement the tool should not make for you. It
|
||||||
|
also refuses to offer an **FK-backed** index as a drop candidate — dropping one
|
||||||
|
fails with errno 150.
|
||||||
|
|
||||||
|
**Prefer `--emit-migration` over `--apply-indexes` in MT.** An index applied by
|
||||||
|
hand to one tenant leaves every other tenant's schema different from it; a
|
||||||
|
migration reaches all of them through the normal
|
||||||
|
`python -m control.tenant_migrate upgrade --tenant all`. `--emit-migration`
|
||||||
|
writes ONE re-runnable migration for the union of what every inspected tenant is
|
||||||
|
missing (the chain is shared — emitting one per tenant would produce conflicting
|
||||||
|
revisions), with INFORMATION_SCHEMA guards per rule 16. It guesses
|
||||||
|
`down_revision` from the versions directory — confirm against
|
||||||
|
`python -m control.tenant_migrate heads` before committing.
|
||||||
|
|
||||||
|
`RECOMMENDED_INDEXES` in the script is the **single place** the index wish-list
|
||||||
|
lives, and every entry names the query that justifies it. An index nothing runs
|
||||||
|
is pure write-amplification, so keep speculative entries out — and when a new
|
||||||
|
hot query lands, add its index there rather than to an ad-hoc migration, so the
|
||||||
|
checker keeps agreeing with the schema.
|
||||||
|
|
||||||
|
The connection-ceiling check is MT-aware — see **rule 111**.
|
||||||
|
|||||||
@@ -285,8 +285,8 @@ def list_inspections():
|
|||||||
if user.role not in _ALLOWED_ROLES:
|
if user.role not in _ALLOWED_ROLES:
|
||||||
return api_error('Access denied', 403)
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
limit = min(int(request.args.get('limit', 50)), 200)
|
limit = min(request.args.get('limit', 50, type=int) or 50, 200)
|
||||||
offset = max(int(request.args.get('offset', 0)), 0)
|
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
|
||||||
|
|
||||||
query = Inspection.query
|
query = Inspection.query
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -152,8 +152,8 @@ def list_issues():
|
|||||||
if user.role not in _ALLOWED_ROLES:
|
if user.role not in _ALLOWED_ROLES:
|
||||||
return api_error('Access denied', 403)
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
limit = min(int(request.args.get('limit', 100)), 200)
|
limit = min(request.args.get('limit', 100, type=int) or 100, 200)
|
||||||
offset = max(int(request.args.get('offset', 0)), 0)
|
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
|
||||||
|
|
||||||
query = Issue.query
|
query = Issue.query
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def list_notifications():
|
|||||||
"""
|
"""
|
||||||
user = g.api_user
|
user = g.api_user
|
||||||
since = _parse_since(request.args.get('since'))
|
since = _parse_since(request.args.get('since'))
|
||||||
limit = min(int(request.args.get('limit', 50)), 50)
|
limit = min(request.args.get('limit', 50, type=int) or 50, 50)
|
||||||
|
|
||||||
def _run_orm():
|
def _run_orm():
|
||||||
q = Notification.query.filter_by(user_id=user.id, is_read=False)
|
q = Notification.query.filter_by(user_id=user.id, is_read=False)
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ def list_scheduled():
|
|||||||
return api_error('Access denied', 403)
|
return api_error('Access denied', 403)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
limit = min(int(request.args.get('limit', 100)), 200)
|
limit = min(request.args.get('limit', 100, type=int) or 100, 200)
|
||||||
offset = max(int(request.args.get('offset', 0)), 0)
|
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return api_error('limit and offset must be integers', 400)
|
return api_error('limit and offset must be integers', 400)
|
||||||
|
|
||||||
|
|||||||
+8
-1
@@ -111,7 +111,14 @@ def dashboard_stats():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
open_issues_all = open_q.all()
|
# Counts and buckets only — never a hydrated Issue. For an admin this is
|
||||||
|
# every open issue in the system, fetched on every iPad dashboard refresh;
|
||||||
|
# the full entity would drag the description TEXT and the JSON photo
|
||||||
|
# columns along with it. A Row exposes the same attribute names, so
|
||||||
|
# sla_status() below works unchanged.
|
||||||
|
open_issues_all = open_q.with_entities(
|
||||||
|
Issue.id, Issue.severity, Issue.status, Issue.reported_at
|
||||||
|
).all()
|
||||||
open_issues = len(open_issues_all)
|
open_issues = len(open_issues_all)
|
||||||
|
|
||||||
# ── Severity breakdown (derived from the same open_issues_all list) ───
|
# ── Severity breakdown (derived from the same open_issues_all list) ───
|
||||||
|
|||||||
@@ -43,6 +43,122 @@ class IssueFollower(db.Model):
|
|||||||
return f'<IssueFollower issue={self.issue_id} user={self.user_id}>'
|
return f'<IssueFollower issue={self.issue_id} user={self.user_id}>'
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ── Issue Link ────────────────────────────────────────────────────────────────
|
||||||
|
# Connects two issues so staff can jump between a duplicate and the original, or
|
||||||
|
# between issues that are simply about the same thing.
|
||||||
|
|
||||||
|
class IssueLink(db.Model):
|
||||||
|
"""One directed link between two issues, displayed on BOTH of them.
|
||||||
|
|
||||||
|
Only one row is stored per pair. The stored direction carries meaning for
|
||||||
|
'duplicate' — issue_id is a duplicate OF linked_issue_id — so the two issues
|
||||||
|
read the same row differently:
|
||||||
|
|
||||||
|
on issue_id -> "Duplicate of #B"
|
||||||
|
on linked_issue_id -> "Duplicated by #A"
|
||||||
|
|
||||||
|
'related' is symmetric and reads "Related to" from either side.
|
||||||
|
|
||||||
|
Storing one row rather than a mirrored pair is what keeps the direction
|
||||||
|
unambiguous and makes unlinking a single delete. The cost is that uniqueness
|
||||||
|
cannot be expressed by the UniqueConstraint alone: (A,B) and (B,A) are
|
||||||
|
distinct rows to the database but the same link to a person, so the
|
||||||
|
duplicate check has to look in both directions. exists_between() is that
|
||||||
|
check, and it is the only thing callers should use.
|
||||||
|
|
||||||
|
A link is PURELY NAVIGATIONAL. Marking a duplicate does not touch either
|
||||||
|
issue's status, SLA, assignee or followers — closing the duplicate stays a
|
||||||
|
deliberate, separate action.
|
||||||
|
|
||||||
|
Multi-tenant: nothing here is tenant-aware, and deliberately so. The table
|
||||||
|
lives in the tenant database and every query routes through RoutingSession,
|
||||||
|
so a link can only ever reach an issue in the same tenant. Scope WITHIN a
|
||||||
|
tenant is the caller's job — see _readable_links() in routes/issues.py.
|
||||||
|
"""
|
||||||
|
__tablename__ = 'issue_links'
|
||||||
|
|
||||||
|
TYPE_DUPLICATE = 'duplicate'
|
||||||
|
TYPE_RELATED = 'related'
|
||||||
|
|
||||||
|
# How each link type reads from the two sides, keyed by (type, is_source).
|
||||||
|
LABELS = {
|
||||||
|
('duplicate', True): 'Duplicate of',
|
||||||
|
('duplicate', False): 'Duplicated by',
|
||||||
|
('related', True): 'Related to',
|
||||||
|
('related', False): 'Related to',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Offered in the "Link an issue" picker. The value is what gets stored; the
|
||||||
|
# phrasing is from the point of view of the issue being viewed.
|
||||||
|
TYPE_CHOICES = [
|
||||||
|
('duplicate', 'Duplicate of'),
|
||||||
|
('related', 'Related to'),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
issue_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey('issues.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True)
|
||||||
|
linked_issue_id = db.Column(db.Integer,
|
||||||
|
db.ForeignKey('issues.id', ondelete='CASCADE'),
|
||||||
|
nullable=False, index=True)
|
||||||
|
link_type = db.Column(db.Enum('duplicate', 'related'),
|
||||||
|
nullable=False, default='related')
|
||||||
|
created_by = db.Column(db.Integer,
|
||||||
|
db.ForeignKey('users.id', ondelete='SET NULL'),
|
||||||
|
nullable=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=now_eastern, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
# Catches the exact-duplicate row at the database level. The REVERSE
|
||||||
|
# direction is caught by exists_between() — see the class docstring.
|
||||||
|
db.UniqueConstraint('issue_id', 'linked_issue_id', name='uq_issue_link'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# BOTH relationships must pin foreign_keys: two FKs from this table to
|
||||||
|
# issues leave the join condition ambiguous otherwise, and the mapper raises
|
||||||
|
# on first ORM USE rather than at import — the app starts cleanly and then
|
||||||
|
# every request 500s (the phase56 lesson, CLAUDE.md §17).
|
||||||
|
issue = db.relationship('Issue', foreign_keys=[issue_id],
|
||||||
|
back_populates='links_from')
|
||||||
|
linked_issue = db.relationship('Issue', foreign_keys=[linked_issue_id],
|
||||||
|
back_populates='links_to')
|
||||||
|
creator = db.relationship('User', foreign_keys=[created_by])
|
||||||
|
|
||||||
|
def label_for(self, viewing_issue_id):
|
||||||
|
"""How this link reads on the issue currently being viewed."""
|
||||||
|
return self.LABELS[(self.link_type, self.issue_id == viewing_issue_id)]
|
||||||
|
|
||||||
|
def other_issue(self, viewing_issue_id):
|
||||||
|
"""The issue at the far end of this link from the one being viewed."""
|
||||||
|
return (self.linked_issue if self.issue_id == viewing_issue_id
|
||||||
|
else self.issue)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def exists_between(issue_id, other_id):
|
||||||
|
"""True when the two issues are already linked, in EITHER direction.
|
||||||
|
|
||||||
|
The UniqueConstraint only covers the stored direction, so this is what
|
||||||
|
stops #A being linked to #B and then #B linked back to #A as a second,
|
||||||
|
contradictory row.
|
||||||
|
"""
|
||||||
|
return db.session.query(
|
||||||
|
IssueLink.query.filter(
|
||||||
|
db.or_(
|
||||||
|
db.and_(IssueLink.issue_id == issue_id,
|
||||||
|
IssueLink.linked_issue_id == other_id),
|
||||||
|
db.and_(IssueLink.issue_id == other_id,
|
||||||
|
IssueLink.linked_issue_id == issue_id),
|
||||||
|
)
|
||||||
|
).exists()
|
||||||
|
).scalar()
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (f'<IssueLink {self.issue_id} {self.link_type} '
|
||||||
|
f'{self.linked_issue_id}>')
|
||||||
|
|
||||||
|
|
||||||
class Issue(db.Model):
|
class Issue(db.Model):
|
||||||
__tablename__ = 'issues'
|
__tablename__ = 'issues'
|
||||||
|
|
||||||
@@ -118,10 +234,37 @@ class Issue(db.Model):
|
|||||||
followers = db.relationship('IssueFollower', back_populates='issue',
|
followers = db.relationship('IssueFollower', back_populates='issue',
|
||||||
cascade='all, delete-orphan', lazy='dynamic')
|
cascade='all, delete-orphan', lazy='dynamic')
|
||||||
|
|
||||||
|
# An issue link is stored once and shown on both issues, so each issue has
|
||||||
|
# rows pointing OUT of it and rows pointing AT it. Deleting an issue must
|
||||||
|
# take its links with it from BOTH sides, or the surviving issue keeps a row
|
||||||
|
# referencing one that no longer exists.
|
||||||
|
links_from = db.relationship('IssueLink', back_populates='issue',
|
||||||
|
foreign_keys='IssueLink.issue_id',
|
||||||
|
cascade='all, delete-orphan', lazy='dynamic')
|
||||||
|
links_to = db.relationship('IssueLink', back_populates='linked_issue',
|
||||||
|
foreign_keys='IssueLink.linked_issue_id',
|
||||||
|
cascade='all, delete-orphan', lazy='dynamic')
|
||||||
|
|
||||||
def is_followed_by(self, user):
|
def is_followed_by(self, user):
|
||||||
"""Return True if the given user is currently following this issue."""
|
"""Return True if the given user is currently following this issue."""
|
||||||
return self.followers.filter_by(user_id=user.id).first() is not None
|
return self.followers.filter_by(user_id=user.id).first() is not None
|
||||||
|
|
||||||
|
def all_links(self):
|
||||||
|
"""Every link touching this issue, from both directions, newest first.
|
||||||
|
|
||||||
|
The two relationships are a storage detail — a link is one thing to the
|
||||||
|
person reading it, so callers get a single list and ask each row how it
|
||||||
|
reads via label_for() / other_issue().
|
||||||
|
|
||||||
|
Nothing here filters by permission. The caller MUST drop links whose far
|
||||||
|
end the viewer cannot access, or a link becomes a way to read an issue
|
||||||
|
at a facility they hold no assignment to. See _readable_links() in
|
||||||
|
routes/issues.py.
|
||||||
|
"""
|
||||||
|
links = list(self.links_from) + list(self.links_to)
|
||||||
|
links.sort(key=lambda link: link.created_at, reverse=True)
|
||||||
|
return links
|
||||||
|
|
||||||
# Display labels for handler_type. The web templates hardcode these inline;
|
# Display labels for handler_type. The web templates hardcode these inline;
|
||||||
# this mapping exists so the mobile API can return a human-readable label
|
# this mapping exists so the mobile API can return a human-readable label
|
||||||
# without the client duplicating the strings. (phase43)
|
# without the client duplicating the strings. (phase43)
|
||||||
|
|||||||
+12
-2
@@ -17,6 +17,16 @@ bp = Blueprint('dashboard', __name__)
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Columns the dashboard actually reads off an issue row. The cards below need
|
||||||
|
# counts and buckets, never a hydrated Issue — loading the full entity pulls the
|
||||||
|
# description TEXT and three JSON photo columns for every open issue in scope,
|
||||||
|
# on every dashboard load, and registers each one in the identity map.
|
||||||
|
# A Row exposes the same attribute names, so the severity/handler tallies and
|
||||||
|
# sla_status() work against these unchanged.
|
||||||
|
_ISSUE_CARD_COLS = (Issue.id, Issue.severity, Issue.status,
|
||||||
|
Issue.reported_at, Issue.handler_type)
|
||||||
|
|
||||||
|
|
||||||
@bp.route('/')
|
@bp.route('/')
|
||||||
@bp.route('/dashboard')
|
@bp.route('/dashboard')
|
||||||
@login_required
|
@login_required
|
||||||
@@ -100,7 +110,7 @@ def index():
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Single query — derive count from the list to avoid hitting the DB twice
|
# Single query — derive count from the list to avoid hitting the DB twice
|
||||||
open_issues_all = open_issues_q.all()
|
open_issues_all = open_issues_q.with_entities(*_ISSUE_CARD_COLS).all()
|
||||||
open_issues = len(open_issues_all)
|
open_issues = len(open_issues_all)
|
||||||
severity_breakdown = {
|
severity_breakdown = {
|
||||||
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
|
'critical': sum(1 for i in open_issues_all if i.severity == 'critical'),
|
||||||
@@ -226,7 +236,7 @@ def index():
|
|||||||
elif is_customer and not customer_facility_ids:
|
elif is_customer and not customer_facility_ids:
|
||||||
all_open_issues = []
|
all_open_issues = []
|
||||||
else:
|
else:
|
||||||
all_open_issues = sla_q.all()
|
all_open_issues = sla_q.with_entities(*_ISSUE_CARD_COLS).all()
|
||||||
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
|
sla_breached = sum(1 for i in all_open_issues if sla_status(i) == 'breached')
|
||||||
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
|
sla_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
|
||||||
|
|
||||||
|
|||||||
@@ -254,7 +254,8 @@ def index():
|
|||||||
q = q.filter(Inspection.status == status_filter)
|
q = q.filter(Inspection.status == status_filter)
|
||||||
if contract_filter.isdigit():
|
if contract_filter.isdigit():
|
||||||
_contract_fids = [
|
_contract_fids = [
|
||||||
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
fid for (fid,) in db.session.query(Facility.id)
|
||||||
|
.filter(Facility.project_id == int(contract_filter)).all()
|
||||||
]
|
]
|
||||||
q = q.filter(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False)
|
q = q.filter(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False)
|
||||||
if facility_filter.isdigit():
|
if facility_filter.isdigit():
|
||||||
@@ -1259,7 +1260,8 @@ def export_list_pdf():
|
|||||||
q = q.filter(Inspection.status == status_filter)
|
q = q.filter(Inspection.status == status_filter)
|
||||||
if contract_filter.isdigit():
|
if contract_filter.isdigit():
|
||||||
_contract_fids = [
|
_contract_fids = [
|
||||||
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
fid for (fid,) in db.session.query(Facility.id)
|
||||||
|
.filter(Facility.project_id == int(contract_filter)).all()
|
||||||
]
|
]
|
||||||
q = q.filter(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False)
|
q = q.filter(Inspection.facility_id.in_(_contract_fids)) if _contract_fids else q.filter(False)
|
||||||
if facility_filter.isdigit():
|
if facility_filter.isdigit():
|
||||||
|
|||||||
+263
-20
@@ -6,7 +6,7 @@ from flask import (Blueprint, render_template, redirect, url_for,
|
|||||||
flash, request, current_app, jsonify, abort, Response)
|
flash, request, current_app, jsonify, abort, Response)
|
||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
from app import db
|
from app import db
|
||||||
from app.models.issue import Issue, IssueComment, IssueFollower
|
from app.models.issue import Issue, IssueComment, IssueFollower, IssueLink
|
||||||
from app.models.facility import Facility, Area
|
from app.models.facility import Facility, Area
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.notification import (
|
from app.models.notification import (
|
||||||
@@ -71,6 +71,48 @@ class _SLAFilteredPage:
|
|||||||
return iter([1])
|
return iter([1])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Issue read access ─────────────────────────────────────────────────────────
|
||||||
|
# One definition of "may this person open this issue", used by the detail view,
|
||||||
|
# by the linked-issues panel, and by the link picker's search. They must not
|
||||||
|
# drift: the picker is what a person searches, but the panel is what actually
|
||||||
|
# renders another issue's description, and the POST is the real boundary.
|
||||||
|
#
|
||||||
|
# All three are WITHIN one tenant. Cross-tenant isolation is not their job and
|
||||||
|
# never can be — RoutingSession has already bound the session to g.tenant's
|
||||||
|
# database, so an id from another tenant simply does not resolve here.
|
||||||
|
|
||||||
|
def _viewer_facility_scope(user):
|
||||||
|
"""Facility ids this user is confined to, or None when unrestricted.
|
||||||
|
|
||||||
|
Returns a LIST (possibly empty) for the two scoped role groups and None for
|
||||||
|
everyone else. Empty list and None mean opposite things — [] is "no access
|
||||||
|
to anything", None is "no restriction" — so callers must test `is None`
|
||||||
|
rather than truthiness (CLAUDE.md rule 57's failure mode).
|
||||||
|
"""
|
||||||
|
if user.is_inspector: # rule 87 — never role == 'inspector'
|
||||||
|
return get_inspector_scope(user) or []
|
||||||
|
if user.role == 'customer': # rule 99 — capability check, exact match
|
||||||
|
return get_customer_scope(user) or []
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_in_scope(issue, scope_ids):
|
||||||
|
"""Whether one issue falls inside an already-resolved facility scope.
|
||||||
|
|
||||||
|
Takes the scope rather than the user so a caller filtering a list of issues
|
||||||
|
resolves it once instead of re-querying the assignment tables per row.
|
||||||
|
"""
|
||||||
|
if scope_ids is None:
|
||||||
|
return True
|
||||||
|
facility = issue.resolved_facility
|
||||||
|
return facility is not None and facility.id in scope_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _issue_readable_by(issue, user):
|
||||||
|
"""Single-issue convenience wrapper around the two helpers above."""
|
||||||
|
return _issue_in_scope(issue, _viewer_facility_scope(user))
|
||||||
|
|
||||||
|
|
||||||
def _assignee_label(user):
|
def _assignee_label(user):
|
||||||
"""Dropdown label for an assignee.
|
"""Dropdown label for an assignee.
|
||||||
|
|
||||||
@@ -146,13 +188,14 @@ def export_list_pdf():
|
|||||||
date_to_filter = ''
|
date_to_filter = ''
|
||||||
if contract_filter.isdigit():
|
if contract_filter.isdigit():
|
||||||
_contract_fids = [
|
_contract_fids = [
|
||||||
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
fid for (fid,) in db.session.query(Facility.id)
|
||||||
|
.filter(Facility.project_id == int(contract_filter)).all()
|
||||||
]
|
]
|
||||||
q = q.filter(db.or_(
|
q = q.filter(db.or_(
|
||||||
Issue.facility_id.in_(_contract_fids),
|
Issue.facility_id.in_(_contract_fids),
|
||||||
Area.facility_id.in_(_contract_fids),
|
Area.facility_id.in_(_contract_fids),
|
||||||
)) if _contract_fids else q.filter(False)
|
)) if _contract_fids else q.filter(False)
|
||||||
if facility_filter:
|
if facility_filter.isdigit():
|
||||||
fid = int(facility_filter)
|
fid = int(facility_filter)
|
||||||
q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid))
|
q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid))
|
||||||
if reporter_filter.isdigit():
|
if reporter_filter.isdigit():
|
||||||
@@ -181,7 +224,7 @@ def export_list_pdf():
|
|||||||
p = db.session.get(Project, int(contract_filter))
|
p = db.session.get(Project, int(contract_filter))
|
||||||
if p:
|
if p:
|
||||||
filter_parts.append(f'Contract: {p.name}')
|
filter_parts.append(f'Contract: {p.name}')
|
||||||
if facility_filter:
|
if facility_filter.isdigit():
|
||||||
f = db.session.get(Facility, int(facility_filter))
|
f = db.session.get(Facility, int(facility_filter))
|
||||||
if f:
|
if f:
|
||||||
filter_parts.append(f'Facility: {f.name}')
|
filter_parts.append(f'Facility: {f.name}')
|
||||||
@@ -279,7 +322,8 @@ def index():
|
|||||||
date_to_filter = ''
|
date_to_filter = ''
|
||||||
if contract_filter.isdigit():
|
if contract_filter.isdigit():
|
||||||
_contract_fids = [
|
_contract_fids = [
|
||||||
f.id for f in Facility.query.filter_by(project_id=int(contract_filter)).all()
|
fid for (fid,) in db.session.query(Facility.id)
|
||||||
|
.filter(Facility.project_id == int(contract_filter)).all()
|
||||||
]
|
]
|
||||||
q = q.filter(db.or_(
|
q = q.filter(db.or_(
|
||||||
Issue.facility_id.in_(_contract_fids),
|
Issue.facility_id.in_(_contract_fids),
|
||||||
@@ -287,7 +331,7 @@ def index():
|
|||||||
)) if _contract_fids else q.filter(False)
|
)) if _contract_fids else q.filter(False)
|
||||||
if reporter_filter.isdigit():
|
if reporter_filter.isdigit():
|
||||||
q = q.filter(Issue.reported_by == int(reporter_filter))
|
q = q.filter(Issue.reported_by == int(reporter_filter))
|
||||||
if facility_filter:
|
if facility_filter.isdigit():
|
||||||
fid = int(facility_filter)
|
fid = int(facility_filter)
|
||||||
q = q.filter(
|
q = q.filter(
|
||||||
db.or_(
|
db.or_(
|
||||||
@@ -325,8 +369,9 @@ def index():
|
|||||||
# can render the following badge and inline unfollow button without an
|
# can render the following badge and inline unfollow button without an
|
||||||
# additional query per row.
|
# additional query per row.
|
||||||
followed_ids = {
|
followed_ids = {
|
||||||
f.issue_id
|
iid for (iid,) in
|
||||||
for f in IssueFollower.query.filter_by(user_id=current_user.id).all()
|
db.session.query(IssueFollower.issue_id)
|
||||||
|
.filter(IssueFollower.user_id == current_user.id).all()
|
||||||
}
|
}
|
||||||
|
|
||||||
# Facilities for the filter dropdown — scoped for inspectors/customers,
|
# Facilities for the filter dropdown — scoped for inspectors/customers,
|
||||||
@@ -398,18 +443,14 @@ def view(issue_id):
|
|||||||
if issue is None:
|
if issue is None:
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
if current_user.is_inspector:
|
# Scope gate — see _issue_readable_by(). This was two inline blocks that the
|
||||||
fids = get_inspector_scope(current_user)
|
# linked-issues panel would have had to reproduce a third time; it is now
|
||||||
facility = issue.resolved_facility
|
# one definition so the panel cannot end up more permissive than the page.
|
||||||
if not fids or not facility or facility.id not in fids:
|
if not _issue_readable_by(issue, current_user):
|
||||||
flash('Access denied.', 'danger')
|
flash('Access denied.', 'danger')
|
||||||
return redirect(url_for('issues.index'))
|
return redirect(url_for('issues.index'))
|
||||||
|
|
||||||
if current_user.role == 'customer':
|
if current_user.role == 'customer':
|
||||||
cids = get_customer_scope(current_user) or []
|
|
||||||
facility = issue.resolved_facility
|
|
||||||
if not facility or facility.id not in cids:
|
|
||||||
flash('Access denied.', 'danger')
|
|
||||||
return redirect(url_for('issues.index'))
|
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
# Customers may only add a comment, and only on issues they follow or reported
|
# Customers may only add a comment, and only on issues they follow or reported
|
||||||
can_comment = (issue.is_followed_by(current_user) or issue.reported_by == current_user.id)
|
can_comment = (issue.is_followed_by(current_user) or issue.reported_by == current_user.id)
|
||||||
@@ -686,7 +727,209 @@ def view(issue_id):
|
|||||||
form=form,
|
form=form,
|
||||||
comments=comments,
|
comments=comments,
|
||||||
comments_open=comments_open,
|
comments_open=comments_open,
|
||||||
is_following=is_following)
|
is_following=is_following,
|
||||||
|
# Already filtered to links whose far end this viewer
|
||||||
|
# may open — see _readable_links().
|
||||||
|
issue_links=_readable_links(issue, current_user),
|
||||||
|
link_types=IssueLink.TYPE_CHOICES,
|
||||||
|
can_manage_links=_can_manage_links(issue, current_user))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Issue links ───────────────────────────────────────────────────────────────
|
||||||
|
# Connect a duplicate to its original, or two issues about the same thing, so
|
||||||
|
# whoever picks one up can reach the other. Links are purely navigational: they
|
||||||
|
# never touch status, SLA, assignee or followers on either issue.
|
||||||
|
|
||||||
|
def _can_manage_links(issue, user):
|
||||||
|
"""Who may add or remove a link on this issue.
|
||||||
|
|
||||||
|
Deliberately the SAME set as the page's `can_edit` (the Update Issue panel):
|
||||||
|
admin / director / auditor, or the person the issue is assigned to. Keeping
|
||||||
|
the two identical means the panel's buttons and this gate cannot disagree —
|
||||||
|
the alternative is a second, slightly different rule that nobody remembers.
|
||||||
|
Widening it (to project_manager, or to the reporter) is a one-line change
|
||||||
|
here, but change `can_edit` in issues/view.html at the same time.
|
||||||
|
"""
|
||||||
|
return (user.role in ('admin', 'director', 'auditor')
|
||||||
|
or issue.assigned_to == user.id)
|
||||||
|
|
||||||
|
|
||||||
|
def _readable_links(issue, user):
|
||||||
|
"""Links on this issue whose FAR END the viewer may also open.
|
||||||
|
|
||||||
|
A link is a pointer to another issue's id, description and facility, so an
|
||||||
|
unfiltered panel would let a customer read an issue at a facility they have
|
||||||
|
no assignment to simply because one of our staff linked it. The scope is
|
||||||
|
resolved once for the whole list rather than per row.
|
||||||
|
|
||||||
|
Returns a list of (link, other_issue, label) ready for the template.
|
||||||
|
"""
|
||||||
|
scope = _viewer_facility_scope(user)
|
||||||
|
visible = []
|
||||||
|
for link in issue.all_links():
|
||||||
|
other = link.other_issue(issue.id)
|
||||||
|
if other is None or not _issue_in_scope(other, scope):
|
||||||
|
continue
|
||||||
|
visible.append((link, other, link.label_for(issue.id)))
|
||||||
|
return visible
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:issue_id>/links', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_link(issue_id):
|
||||||
|
"""Link this issue to another one."""
|
||||||
|
issue = db.session.get(Issue, issue_id)
|
||||||
|
if issue is None:
|
||||||
|
abort(404)
|
||||||
|
if not _issue_readable_by(issue, current_user):
|
||||||
|
abort(403)
|
||||||
|
if not _can_manage_links(issue, current_user):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
link_type = request.form.get('link_type', '')
|
||||||
|
if link_type not in (IssueLink.TYPE_DUPLICATE, IssueLink.TYPE_RELATED):
|
||||||
|
flash('Choose how the two issues are related.', 'warning')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
raw_target = (request.form.get('linked_issue_id') or '').strip().lstrip('#')
|
||||||
|
if not raw_target.isdigit():
|
||||||
|
flash('Enter the number of the issue to link, e.g. 412.', 'warning')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
target_id = int(raw_target)
|
||||||
|
|
||||||
|
if target_id == issue.id:
|
||||||
|
flash('An issue cannot be linked to itself.', 'warning')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
target = db.session.get(Issue, target_id)
|
||||||
|
# A 404 and a 403 are the same message here on purpose: whether an issue
|
||||||
|
# outside your scope EXISTS is not something the link box should confirm.
|
||||||
|
if target is None or not _issue_readable_by(target, current_user):
|
||||||
|
flash(f'Issue #{target_id} was not found.', 'warning')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
if IssueLink.exists_between(issue.id, target.id):
|
||||||
|
flash(f'Issue #{issue.id} and #{target.id} are already linked.', 'info')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
link = IssueLink(
|
||||||
|
issue_id = issue.id,
|
||||||
|
linked_issue_id = target.id,
|
||||||
|
link_type = link_type,
|
||||||
|
created_by = current_user.id,
|
||||||
|
)
|
||||||
|
db.session.add(link)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||||
|
f'linked to #{target.id} as {link_type}')
|
||||||
|
current_app.logger.info(
|
||||||
|
'ISSUE LINK | issue_id=%s | linked_issue_id=%s | type=%s | user=%s',
|
||||||
|
issue.id, target.id, link_type, current_user.username,
|
||||||
|
)
|
||||||
|
flash(f'Issue #{issue.id} is now linked to #{target.id}.', 'success')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:issue_id>/links/<int:link_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def remove_link(issue_id, link_id):
|
||||||
|
"""Remove a link. Either end of it may do this."""
|
||||||
|
issue = db.session.get(Issue, issue_id)
|
||||||
|
if issue is None:
|
||||||
|
abort(404)
|
||||||
|
if not _issue_readable_by(issue, current_user):
|
||||||
|
abort(403)
|
||||||
|
if not _can_manage_links(issue, current_user):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
link = db.session.get(IssueLink, link_id)
|
||||||
|
# The link must actually touch THIS issue. Without the check, anyone able to
|
||||||
|
# manage links on any one issue could delete a link between two others by
|
||||||
|
# posting its id here.
|
||||||
|
if link is None or issue.id not in (link.issue_id, link.linked_issue_id):
|
||||||
|
flash('That link no longer exists.', 'info')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
other_id = link.linked_issue_id if link.issue_id == issue.id else link.issue_id
|
||||||
|
db.session.delete(link)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id}',
|
||||||
|
f'unlinked from #{other_id}')
|
||||||
|
current_app.logger.info(
|
||||||
|
'ISSUE UNLINK | issue_id=%s | linked_issue_id=%s | user=%s',
|
||||||
|
issue.id, other_id, current_user.username,
|
||||||
|
)
|
||||||
|
flash(f'Removed the link to issue #{other_id}.', 'info')
|
||||||
|
return redirect(_view_url(issue_id))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route('/<int:issue_id>/link-search')
|
||||||
|
@login_required
|
||||||
|
def link_search(issue_id):
|
||||||
|
"""JSON candidates for the link picker.
|
||||||
|
|
||||||
|
Scoped exactly like the issue list, so an inspector or customer can only
|
||||||
|
find issues they could already open — searching must not become a way to
|
||||||
|
enumerate another contract's issues. The results are a convenience; the POST
|
||||||
|
in add_link() re-checks access and is the real boundary.
|
||||||
|
"""
|
||||||
|
issue = db.session.get(Issue, issue_id)
|
||||||
|
if issue is None:
|
||||||
|
abort(404)
|
||||||
|
if not _issue_readable_by(issue, current_user):
|
||||||
|
abort(403)
|
||||||
|
|
||||||
|
term = (request.args.get('q') or '').strip().lstrip('#')
|
||||||
|
if len(term) < 1:
|
||||||
|
return jsonify({'results': []})
|
||||||
|
|
||||||
|
q = (
|
||||||
|
Issue.query
|
||||||
|
.outerjoin(Area, Issue.area_id == Area.id)
|
||||||
|
.options(joinedload(Issue.facility), contains_eager(Issue.area))
|
||||||
|
.filter(Issue.id != issue.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
scope = _viewer_facility_scope(current_user)
|
||||||
|
if scope is not None:
|
||||||
|
if not scope:
|
||||||
|
return jsonify({'results': []})
|
||||||
|
q = q.filter(db.or_(
|
||||||
|
Issue.facility_id.in_(scope),
|
||||||
|
db.and_(Issue.area_id.isnot(None), Area.facility_id.in_(scope)),
|
||||||
|
))
|
||||||
|
|
||||||
|
# Exclude issues already linked in either direction — offering them only
|
||||||
|
# produces an "already linked" flash.
|
||||||
|
linked_ids = {other.id for _l, other, _lbl in _readable_links(issue, current_user)}
|
||||||
|
if linked_ids:
|
||||||
|
q = q.filter(Issue.id.notin_(linked_ids))
|
||||||
|
|
||||||
|
if term.isdigit():
|
||||||
|
# A number is almost always an issue number, so match the id first and
|
||||||
|
# fall back to the description for things like "Room 204".
|
||||||
|
q = q.filter(db.or_(Issue.id == int(term),
|
||||||
|
Issue.description.ilike(f'%{term}%')))
|
||||||
|
else:
|
||||||
|
q = q.filter(Issue.description.ilike(f'%{term}%'))
|
||||||
|
|
||||||
|
matches = q.order_by(Issue.reported_at.desc()).limit(10).all()
|
||||||
|
|
||||||
|
return jsonify({'results': [
|
||||||
|
{
|
||||||
|
'id': i.id,
|
||||||
|
'description': (i.description or '')[:110],
|
||||||
|
'status': (i.status or '').replace('_', ' ').title(),
|
||||||
|
'severity': (i.severity or '').title(),
|
||||||
|
'location': (i.area.name if i.area
|
||||||
|
else i.resolved_facility.name if i.resolved_facility
|
||||||
|
else '—'),
|
||||||
|
'reported_at': i.reported_at.strftime('%Y-%m-%d') if i.reported_at else '',
|
||||||
|
}
|
||||||
|
for i in matches
|
||||||
|
]})
|
||||||
|
|
||||||
|
|
||||||
# ── Follow ────────────────────────────────────────────────────────────────────
|
# ── Follow ────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
+19
-19
@@ -74,23 +74,29 @@ def index():
|
|||||||
if current_user.role in ('admin', 'director', 'project_manager'):
|
if current_user.role in ('admin', 'director', 'project_manager'):
|
||||||
inspector_filter = request.args.get('inspector_id', type=int) or None
|
inspector_filter = request.args.get('inspector_id', type=int) or None
|
||||||
|
|
||||||
# Pre-compute inspection ID sets used by _scope_issue to avoid join conflicts.
|
# Scope issues by the relevant inspector's inspections, as a SUBQUERY rather
|
||||||
inspector_inspection_ids = [] # own inspections (inspector role)
|
# than a materialised id list. The previous form pulled every inspection id
|
||||||
filter_inspection_ids = None # filtered inspector's inspections (admin/dir/PM)
|
# that inspector had ever performed into Python and sent them straight back
|
||||||
|
# as a literal IN (1, 2, 3, ... N): the round trip is wasted, the statement
|
||||||
|
# grows without bound with the inspector's history, and a long enough list
|
||||||
|
# eventually trips max_allowed_packet. A subquery is also still a single
|
||||||
|
# statement, so the "avoid join conflicts" reason for pre-computing holds.
|
||||||
|
#
|
||||||
|
# IN (empty subquery) already matches nothing, so the explicit empty-list
|
||||||
|
# guards the old code needed are gone rather than merely moved.
|
||||||
|
inspector_insp_subq = None
|
||||||
if is_inspector:
|
if is_inspector:
|
||||||
inspector_inspection_ids = [
|
inspector_insp_subq = (
|
||||||
row[0] for row in
|
|
||||||
db.session.query(Inspection.id)
|
db.session.query(Inspection.id)
|
||||||
.filter(Inspection.inspector_id == current_user.id)
|
.filter(Inspection.inspector_id == current_user.id)
|
||||||
.all()
|
.scalar_subquery()
|
||||||
]
|
)
|
||||||
elif inspector_filter:
|
elif inspector_filter:
|
||||||
filter_inspection_ids = [
|
inspector_insp_subq = (
|
||||||
row[0] for row in
|
|
||||||
db.session.query(Inspection.id)
|
db.session.query(Inspection.id)
|
||||||
.filter(Inspection.inspector_id == inspector_filter)
|
.filter(Inspection.inspector_id == inspector_filter)
|
||||||
.all()
|
.scalar_subquery()
|
||||||
]
|
)
|
||||||
|
|
||||||
def _scope_insp(q):
|
def _scope_insp(q):
|
||||||
if is_inspector:
|
if is_inspector:
|
||||||
@@ -104,14 +110,8 @@ def index():
|
|||||||
return q
|
return q
|
||||||
|
|
||||||
def _scope_issue(q):
|
def _scope_issue(q):
|
||||||
if is_inspector:
|
if inspector_insp_subq is not None:
|
||||||
if not inspector_inspection_ids:
|
return q.filter(Issue.inspection_id.in_(inspector_insp_subq))
|
||||||
return q.filter(False)
|
|
||||||
return q.filter(Issue.inspection_id.in_(inspector_inspection_ids))
|
|
||||||
if filter_inspection_ids is not None:
|
|
||||||
if not filter_inspection_ids:
|
|
||||||
return q.filter(False)
|
|
||||||
return q.filter(Issue.inspection_id.in_(filter_inspection_ids))
|
|
||||||
if customer_facility_ids is not None:
|
if customer_facility_ids is not None:
|
||||||
if not customer_facility_ids:
|
if not customer_facility_ids:
|
||||||
return q.filter(False)
|
return q.filter(False)
|
||||||
|
|||||||
@@ -227,6 +227,84 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Linked issues ──────────────────────────────────────────────────────
|
||||||
|
Duplicates and related issues, so whoever picks this one up can reach the
|
||||||
|
others. `issue_links` arrives already filtered to links whose far end this
|
||||||
|
viewer may open (_readable_links) — do NOT add links from the model
|
||||||
|
directly here, or a customer sees an issue at a facility they have no
|
||||||
|
assignment to. Links are navigational only: nothing here changes status,
|
||||||
|
SLA, assignee or followers on either issue. #}
|
||||||
|
<div class="card shadow-sm mb-4" id="linked-issues-section">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Linked Issues
|
||||||
|
<span class="badge bg-secondary rounded-pill ms-1">{{ issue_links|length }}</span>
|
||||||
|
</h6>
|
||||||
|
{% if can_manage_links %}
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#linkIssueModal">
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Link an Issue
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body py-2">
|
||||||
|
{% if issue_links %}
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for link, other, label in issue_links %}
|
||||||
|
<div class="list-group-item px-0 py-2 d-flex align-items-start gap-2 flex-wrap">
|
||||||
|
<span class="badge {{ 'bg-warning text-dark' if link.link_type == 'duplicate' else 'bg-info text-dark' }} mt-1"
|
||||||
|
style="min-width:7.5rem;">{{ label }}</span>
|
||||||
|
|
||||||
|
<div class="flex-grow-1" style="min-width:14rem;">
|
||||||
|
<a href="{{ url_for('issues.view', issue_id=other.id, next=back_url) }}"
|
||||||
|
class="fw-semibold text-decoration-none">#{{ other.id }}</a>
|
||||||
|
<span class="text-muted small ms-1">
|
||||||
|
{{ other.area.name if other.area
|
||||||
|
else other.resolved_facility.name if other.resolved_facility else '—' }}
|
||||||
|
</span>
|
||||||
|
<div class="small text-muted text-truncate" style="max-width:38rem;">
|
||||||
|
{{ other.description }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center gap-1 mt-1">
|
||||||
|
<span class="badge bg-{{ 'danger' if other.severity in ['critical','high']
|
||||||
|
else 'warning text-dark' if other.severity == 'medium'
|
||||||
|
else 'secondary' }}">{{ other.severity|title }}</span>
|
||||||
|
<span class="badge bg-{{ 'success' if other.status == 'resolved'
|
||||||
|
else 'info text-dark' if other.status == 'pending_verification'
|
||||||
|
else 'light text-dark' }}">
|
||||||
|
{{ other.status|replace('_',' ')|title }}
|
||||||
|
</span>
|
||||||
|
{% if can_manage_links %}
|
||||||
|
<form method="POST" class="mb-0 ms-1"
|
||||||
|
action="{{ url_for('issues.remove_link', issue_id=issue.id, link_id=link.id) }}"
|
||||||
|
onsubmit="return confirm('Remove the link between #{{ issue.id }} and #{{ other.id }}? Neither issue is changed or deleted.');">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
|
<button type="submit" class="btn btn-sm btn-link text-muted p-0 px-1"
|
||||||
|
title="Remove this link">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-muted small mb-0 py-1">
|
||||||
|
<i class="bi bi-info-circle me-1"></i>
|
||||||
|
No linked issues.
|
||||||
|
{% if can_manage_links %}
|
||||||
|
Use <strong>Link an Issue</strong> to point at a duplicate or a related issue.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# ── Comments ───────────────────────────────────────────────────────── #}
|
{# ── Comments ───────────────────────────────────────────────────────── #}
|
||||||
<div class="card shadow-sm mb-4" id="comments-section">
|
<div class="card shadow-sm mb-4" id="comments-section">
|
||||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
@@ -564,6 +642,77 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# ── Link an issue ────────────────────────────────────────────────────────────
|
||||||
|
Search is scoped server-side to issues this viewer could already open, so the
|
||||||
|
picker can never be used to enumerate another contract's issues. The POST
|
||||||
|
re-checks access — the search is only a convenience. #}
|
||||||
|
{% if can_manage_links %}
|
||||||
|
<div class="modal fade" id="linkIssueModal" tabindex="-1"
|
||||||
|
aria-labelledby="linkIssueModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<form method="POST" action="{{ url_for('issues.add_link', issue_id=issue.id) }}">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="next" value="{{ back_url }}">
|
||||||
|
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="linkIssueModalLabel">
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Link an issue to #{{ issue.id }}
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small fw-semibold" for="linkTypeSelect">
|
||||||
|
How are they related?
|
||||||
|
</label>
|
||||||
|
<select name="link_type" id="linkTypeSelect" class="form-select form-select-sm">
|
||||||
|
{% for value, label in link_types %}
|
||||||
|
<option value="{{ value }}">
|
||||||
|
#{{ issue.id }} is a <strong>{{ label|lower }}</strong> …
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<div class="form-text">
|
||||||
|
Linking is for navigation only — neither issue's status, SLA or
|
||||||
|
assignee changes.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small fw-semibold" for="linkIssueSearch">
|
||||||
|
Which issue?
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="linkIssueSearch"
|
||||||
|
autocomplete="off" placeholder="Issue number, or words from the description…">
|
||||||
|
<input type="hidden" name="linked_issue_id" id="linkIssueId">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Chosen issue, shown once picked so nobody submits a mistyped number #}
|
||||||
|
<div id="linkIssueChosen" class="alert alert-primary py-2 small d-none mb-2">
|
||||||
|
<span id="linkIssueChosenText"></span>
|
||||||
|
<button type="button" class="btn btn-sm btn-link p-0 ms-2" id="linkIssueClear">change</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="linkIssueResults" class="list-group small" style="max-height:16rem; overflow-y:auto;"></div>
|
||||||
|
<div id="linkIssueEmpty" class="text-muted small d-none py-2">
|
||||||
|
No matching issue you can access.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm" id="linkIssueSubmit" disabled>
|
||||||
|
<i class="bi bi-link-45deg me-1"></i>Link Issue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if current_user.role in ['admin', 'director'] %}
|
{% if current_user.role in ['admin', 'director'] %}
|
||||||
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
|
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
@@ -619,6 +768,122 @@
|
|||||||
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
section.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// ── Link-an-issue picker ────────────────────────────────────────────────
|
||||||
|
// Type a number or some words, pick from the scoped results, submit. The
|
||||||
|
// hidden linked_issue_id is only ever set by CHOOSING a result, so the
|
||||||
|
// number posted is always one the server just confirmed this user can see.
|
||||||
|
//
|
||||||
|
// Every result field is written with textContent / createTextNode, never
|
||||||
|
// innerHTML: `description` is text a person typed and would otherwise be
|
||||||
|
// an XSS hole straight into the page of whoever opens the picker.
|
||||||
|
var linkSearch = document.getElementById('linkIssueSearch');
|
||||||
|
if (linkSearch) {
|
||||||
|
var linkResults = document.getElementById('linkIssueResults');
|
||||||
|
var linkEmpty = document.getElementById('linkIssueEmpty');
|
||||||
|
var linkIdField = document.getElementById('linkIssueId');
|
||||||
|
var linkChosen = document.getElementById('linkIssueChosen');
|
||||||
|
var linkChosenText = document.getElementById('linkIssueChosenText');
|
||||||
|
var linkClear = document.getElementById('linkIssueClear');
|
||||||
|
var linkSubmit = document.getElementById('linkIssueSubmit');
|
||||||
|
var searchTimer = null;
|
||||||
|
var searchSeq = 0;
|
||||||
|
|
||||||
|
function clearChoice() {
|
||||||
|
linkIdField.value = '';
|
||||||
|
linkSubmit.disabled = true;
|
||||||
|
linkChosen.classList.add('d-none');
|
||||||
|
linkSearch.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(item) {
|
||||||
|
linkIdField.value = item.id;
|
||||||
|
linkSubmit.disabled = false;
|
||||||
|
linkChosenText.textContent =
|
||||||
|
'#' + item.id + ' — ' + item.location + ' — ' + item.description;
|
||||||
|
linkChosen.classList.remove('d-none');
|
||||||
|
linkSearch.classList.add('d-none');
|
||||||
|
linkResults.innerHTML = '';
|
||||||
|
linkEmpty.classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderResults(items) {
|
||||||
|
linkResults.innerHTML = '';
|
||||||
|
linkEmpty.classList.toggle('d-none', items.length > 0);
|
||||||
|
|
||||||
|
items.forEach(function (item) {
|
||||||
|
var row = document.createElement('button');
|
||||||
|
row.type = 'button';
|
||||||
|
row.className = 'list-group-item list-group-item-action py-2';
|
||||||
|
|
||||||
|
var head = document.createElement('div');
|
||||||
|
head.className = 'd-flex justify-content-between gap-2';
|
||||||
|
|
||||||
|
var num = document.createElement('span');
|
||||||
|
num.className = 'fw-semibold';
|
||||||
|
num.textContent = '#' + item.id + ' · ' + item.location;
|
||||||
|
|
||||||
|
var meta = document.createElement('span');
|
||||||
|
meta.className = 'text-muted';
|
||||||
|
meta.textContent = item.severity + ' · ' + item.status +
|
||||||
|
(item.reported_at ? ' · ' + item.reported_at : '');
|
||||||
|
|
||||||
|
head.appendChild(num);
|
||||||
|
head.appendChild(meta);
|
||||||
|
|
||||||
|
var desc = document.createElement('div');
|
||||||
|
desc.className = 'text-muted text-truncate';
|
||||||
|
desc.textContent = item.description;
|
||||||
|
|
||||||
|
row.appendChild(head);
|
||||||
|
row.appendChild(desc);
|
||||||
|
row.addEventListener('click', function () { choose(item); });
|
||||||
|
linkResults.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSearch() {
|
||||||
|
var term = linkSearch.value.trim();
|
||||||
|
if (!term) {
|
||||||
|
linkResults.innerHTML = '';
|
||||||
|
linkEmpty.classList.add('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Responses can arrive out of order; only the newest one may render.
|
||||||
|
var seq = ++searchSeq;
|
||||||
|
fetch('{{ url_for("issues.link_search", issue_id=issue.id) }}?q=' +
|
||||||
|
encodeURIComponent(term), { headers: { 'Accept': 'application/json' } })
|
||||||
|
.then(function (res) { return res.ok ? res.json() : { results: [] }; })
|
||||||
|
.then(function (data) {
|
||||||
|
if (seq !== searchSeq) { return; }
|
||||||
|
renderResults(data.results || []);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (seq !== searchSeq) { return; }
|
||||||
|
renderResults([]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
linkSearch.addEventListener('input', function () {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(runSearch, 250);
|
||||||
|
});
|
||||||
|
// The picker lives inside a form — Enter would submit it with no issue
|
||||||
|
// chosen instead of searching.
|
||||||
|
linkSearch.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
runSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
linkClear.addEventListener('click', function () {
|
||||||
|
clearChoice();
|
||||||
|
linkSearch.value = '';
|
||||||
|
linkSearch.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
+22
-14
@@ -18,6 +18,7 @@ that no facility-level scoping is required (full access applies).
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from app import db
|
||||||
from app.models.project import CustomerAssignment
|
from app.models.project import CustomerAssignment
|
||||||
from app.models.facility import Facility
|
from app.models.facility import Facility
|
||||||
|
|
||||||
@@ -43,30 +44,34 @@ def get_customer_scope(user) -> list[int] | None:
|
|||||||
if user.role != 'customer':
|
if user.role != 'customer':
|
||||||
return None # no scoping needed for internal staff
|
return None # no scoping needed for internal staff
|
||||||
|
|
||||||
assignments = CustomerAssignment.query.filter_by(user_id=user.id).all()
|
# Select only the two columns needed. The previous .all() built full
|
||||||
|
# CustomerAssignment ORM objects (and their identity-map entries) purely to
|
||||||
|
# read two integers off each one; this function runs on nearly every
|
||||||
|
# request for a customer, sometimes more than once.
|
||||||
|
assignments = db.session.query(
|
||||||
|
CustomerAssignment.project_id,
|
||||||
|
CustomerAssignment.facility_id,
|
||||||
|
).filter(CustomerAssignment.user_id == user.id).all()
|
||||||
|
|
||||||
if not assignments:
|
if not assignments:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
# Separate direct facility assignments from project-level assignments
|
# Separate direct facility assignments from project-level assignments
|
||||||
direct_facility_ids = {a.facility_id for a in assignments if a.facility_id}
|
direct_facility_ids = {fac_id for _, fac_id in assignments if fac_id}
|
||||||
project_ids = {a.project_id for a in assignments if not a.facility_id}
|
project_ids = {proj_id for proj_id, fac_id in assignments if not fac_id}
|
||||||
|
|
||||||
facility_ids = set(direct_facility_ids)
|
facility_ids = set(direct_facility_ids)
|
||||||
|
|
||||||
# Single bulk query for all project-scoped facilities — replaces the
|
# Single bulk query for all project-scoped facilities — replaces the
|
||||||
# previous per-assignment Facility.query loop (N+1 pattern).
|
# previous per-assignment Facility.query loop (N+1 pattern). Only the id
|
||||||
|
# column is read; nothing here needs a hydrated Facility.
|
||||||
if project_ids:
|
if project_ids:
|
||||||
project_facilities = (
|
facility_ids.update(
|
||||||
Facility.query
|
fid for (fid,) in db.session.query(Facility.id).filter(
|
||||||
.filter(
|
|
||||||
Facility.project_id.in_(project_ids),
|
Facility.project_id.in_(project_ids),
|
||||||
Facility.active == True,
|
Facility.active == True,
|
||||||
)
|
).all()
|
||||||
.all()
|
|
||||||
)
|
)
|
||||||
for f in project_facilities:
|
|
||||||
facility_ids.add(f.id)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
'SCOPE | customer_scope | user_id=%s username=%s facility_ids=%s',
|
'SCOPE | customer_scope | user_id=%s username=%s facility_ids=%s',
|
||||||
@@ -102,16 +107,19 @@ def get_inspector_scope(user) -> list[int] | None:
|
|||||||
|
|
||||||
from app.models.inspector_assignment import InspectorAssignment
|
from app.models.inspector_assignment import InspectorAssignment
|
||||||
|
|
||||||
|
# Column-only selects — see the note in get_customer_scope(). This runs on
|
||||||
|
# every scoped request for both inspector roles.
|
||||||
project_ids = [
|
project_ids = [
|
||||||
a.project_id
|
pid for (pid,) in
|
||||||
for a in InspectorAssignment.query.filter_by(user_id=user.id).all()
|
db.session.query(InspectorAssignment.project_id)
|
||||||
|
.filter(InspectorAssignment.user_id == user.id).all()
|
||||||
]
|
]
|
||||||
|
|
||||||
if not project_ids:
|
if not project_ids:
|
||||||
return [] # strict: no assignments = no access
|
return [] # strict: no assignments = no access
|
||||||
|
|
||||||
facility_ids = [
|
facility_ids = [
|
||||||
f.id for f in Facility.query.filter(
|
fid for (fid,) in db.session.query(Facility.id).filter(
|
||||||
Facility.project_id.in_(project_ids),
|
Facility.project_id.in_(project_ids),
|
||||||
Facility.active == True,
|
Facility.active == True,
|
||||||
).all()
|
).all()
|
||||||
|
|||||||
+31
-4
@@ -110,11 +110,38 @@ def send_sla_alerts():
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# yield_per streams rows in batches of 100 rather than loading all open
|
# Narrow to actual CANDIDATES in SQL rather than reading every open issue
|
||||||
# issues into memory at once. At current scale this is a no-op difference,
|
# and deciding in Python. This runs every 30 minutes forever, so the old
|
||||||
# but it prevents a memory spike if the issue count grows large.
|
# form's cost grew with the whole open-issue backlog even on a quiet night
|
||||||
|
# where nothing was due. Three filters, each mirroring a `continue` below:
|
||||||
|
#
|
||||||
|
# 1. reported_at IS NOT NULL — the column is nullable, and sla_status()
|
||||||
|
# raises TypeError on a NULL (datetime + timedelta). One such row
|
||||||
|
# would abort the entire cron run, so exclude it in SQL.
|
||||||
|
# 2. sla_notified <> 'breached' — the highest level is already sent; the
|
||||||
|
# loop skips these unconditionally.
|
||||||
|
# 3. old enough to be at least at-risk for its OWN severity, i.e.
|
||||||
|
# reported_at <= now - (window * 0.75). A critical issue qualifies
|
||||||
|
# after 3h, a low one after 90h.
|
||||||
|
#
|
||||||
|
# Anything this excludes would have hit a `continue` anyway, so the set of
|
||||||
|
# notifications sent is unchanged — only the rows read are.
|
||||||
|
now = now_eastern()
|
||||||
|
age_clauses = [
|
||||||
|
db.and_(
|
||||||
|
Issue.severity == severity,
|
||||||
|
Issue.reported_at <= now - timedelta(hours=hours * AT_RISK_THRESHOLD),
|
||||||
|
)
|
||||||
|
for severity, hours in SLA_HOURS.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
# yield_per streams the survivors in batches rather than materialising them
|
||||||
|
# all at once.
|
||||||
open_issues = Issue.query.filter(
|
open_issues = Issue.query.filter(
|
||||||
Issue.status.in_(['open', 'in_progress', 'pending_verification'])
|
Issue.status.in_(['open', 'in_progress', 'pending_verification']),
|
||||||
|
Issue.reported_at.isnot(None),
|
||||||
|
db.or_(Issue.sla_notified.is_(None), Issue.sla_notified != 'breached'),
|
||||||
|
db.or_(*age_clauses),
|
||||||
).yield_per(100)
|
).yield_per(100)
|
||||||
|
|
||||||
total_sent = 0
|
total_sent = 0
|
||||||
|
|||||||
@@ -31,6 +31,42 @@ class Config:
|
|||||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||||
SQLALCHEMY_ECHO = False
|
SQLALCHEMY_ECHO = False
|
||||||
|
|
||||||
|
# ── Connection pool (default bind) ──────────────────────────────────────
|
||||||
|
# These apply to the DEFAULT engine built from SQLALCHEMY_DATABASE_URI —
|
||||||
|
# the one used in single-tenant mode, on tenant-exempt paths, and as the
|
||||||
|
# fallback bind. PER-TENANT engines are built in
|
||||||
|
# app/tenancy/engine_cache.py and carry their own (deliberately smaller)
|
||||||
|
# TENANT_ENGINE_* pool settings; changing these does not affect those.
|
||||||
|
#
|
||||||
|
# Without them the pool runs on library defaults, which is where the
|
||||||
|
# intermittent OperationalError 2006 ("MySQL server has gone away") comes
|
||||||
|
# from: MySQL closes a connection after wait_timeout (8h by default) and
|
||||||
|
# SQLAlchemy hands the dead socket to the next request.
|
||||||
|
#
|
||||||
|
# pool_pre_ping — cheap liveness check before a connection is handed out;
|
||||||
|
# a dead one is discarded and replaced transparently.
|
||||||
|
# pool_recycle — retire connections after 30 min, well under any sane
|
||||||
|
# wait_timeout, so they are never the stale ones.
|
||||||
|
# pool_size / — Gunicorn runs sync workers (cpu*2+1), and each worker
|
||||||
|
# max_overflow holds its OWN pool. Library defaults (5 + 10) mean a
|
||||||
|
# 9-worker box can open 135 connections against a
|
||||||
|
# max_connections of 151 — before the per-tenant pools
|
||||||
|
# are counted at all. A sync worker serves one request at
|
||||||
|
# a time, so it needs one connection in steady state; the
|
||||||
|
# small overflow is headroom for background
|
||||||
|
# email/notification threads.
|
||||||
|
#
|
||||||
|
# Tune with scripts/db_health.py, which cross-checks these (and the
|
||||||
|
# per-tenant pools) against the server's live max_connections and
|
||||||
|
# wait_timeout.
|
||||||
|
SQLALCHEMY_ENGINE_OPTIONS = {
|
||||||
|
'pool_pre_ping': True,
|
||||||
|
'pool_recycle': int(os.environ.get('DB_POOL_RECYCLE', '1800')),
|
||||||
|
'pool_size': int(os.environ.get('DB_POOL_SIZE', '5')),
|
||||||
|
'max_overflow': int(os.environ.get('DB_MAX_OVERFLOW', '5')),
|
||||||
|
'pool_timeout': 30,
|
||||||
|
}
|
||||||
|
|
||||||
# ── Multi-tenancy (MT-1) ─────────────────────────────────────────────────
|
# ── Multi-tenancy (MT-1) ─────────────────────────────────────────────────
|
||||||
# Master switch. When False (default) the app behaves EXACTLY as the
|
# Master switch. When False (default) the app behaves EXACTLY as the
|
||||||
# single-tenant deployment: no Host resolution, every query uses
|
# single-tenant deployment: no Host resolution, every query uses
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""phase57 — link related and duplicate issues
|
||||||
|
|
||||||
|
Creates `issue_links`: one row per connection between two issues, displayed on
|
||||||
|
both of them. See the IssueLink docstring in app/models/issue.py for why the
|
||||||
|
direction is stored once rather than mirrored.
|
||||||
|
|
||||||
|
Purely additive. Nothing reads the table until a person creates a link, so an
|
||||||
|
empty table is exactly today's behaviour and there is nothing to backfill.
|
||||||
|
|
||||||
|
Both FKs use ON DELETE CASCADE so deleting an issue takes its links with it from
|
||||||
|
either end — a surviving link pointing at a deleted issue would render a dead
|
||||||
|
row on the other issue's page. The ORM cascade on Issue.links_from/links_to
|
||||||
|
covers the application path; this covers a direct SQL delete.
|
||||||
|
|
||||||
|
Table-existence check — safe to re-run, which is what makes it usable through
|
||||||
|
`python -m control.tenant_migrate upgrade --tenant all` (CLAUDE.md §17: every
|
||||||
|
migration from phase33 on MUST be guarded, because it runs once per tenant DB
|
||||||
|
and a partially-upgraded fleet gets re-run).
|
||||||
|
|
||||||
|
ST calls this phase54; MT's chain was already past that number. Match by NAME.
|
||||||
|
|
||||||
|
Revision ID: phase57_issue_links
|
||||||
|
Revises: phase56_followup_assignee
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# Revision ids must be <= 32 chars — alembic_version.version_num is VARCHAR(32).
|
||||||
|
revision = 'phase57_issue_links'
|
||||||
|
down_revision = 'phase56_followup_assignee'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(conn, name):
|
||||||
|
return conn.execute(sa.text("""
|
||||||
|
SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t
|
||||||
|
"""), {'t': name}).scalar() > 0
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
if _table_exists(conn, 'issue_links'):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.execute(sa.text("""
|
||||||
|
CREATE TABLE issue_links (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
issue_id INT NOT NULL,
|
||||||
|
linked_issue_id INT NOT NULL,
|
||||||
|
link_type ENUM('duplicate','related') NOT NULL DEFAULT 'related',
|
||||||
|
created_by INT NULL,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT uq_issue_link UNIQUE (issue_id, linked_issue_id),
|
||||||
|
|
||||||
|
-- Names match what SQLAlchemy's index=True generates, so the
|
||||||
|
-- schema this migration builds and the one db.create_all() builds
|
||||||
|
-- are identical down to the index names. That matters more in MT
|
||||||
|
-- than in ST: a tenant bootstrapped from the baseline and one
|
||||||
|
-- upgraded through the chain must end up with the same schema.
|
||||||
|
INDEX ix_issue_links_issue_id (issue_id),
|
||||||
|
INDEX ix_issue_links_linked_issue_id (linked_issue_id),
|
||||||
|
|
||||||
|
CONSTRAINT fk_issue_links_issue
|
||||||
|
FOREIGN KEY (issue_id) REFERENCES issues (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_issue_links_linked
|
||||||
|
FOREIGN KEY (linked_issue_id) REFERENCES issues (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_issue_links_creator
|
||||||
|
FOREIGN KEY (created_by) REFERENCES users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
conn = op.get_bind()
|
||||||
|
if _table_exists(conn, 'issue_links'):
|
||||||
|
op.execute(sa.text('DROP TABLE issue_links'))
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,501 @@
|
|||||||
|
"""
|
||||||
|
tests/test_issue_links.py
|
||||||
|
-------------------------
|
||||||
|
phase57 — link a duplicate to its original, or two issues about the same thing.
|
||||||
|
|
||||||
|
Two things are being pinned here.
|
||||||
|
|
||||||
|
**The direction convention.** One row is stored per pair and shown on BOTH
|
||||||
|
issues, so the same row has to read differently at each end ("Duplicate of #B"
|
||||||
|
on one, "Duplicated by #A" on the other). That is what makes `exists_between()`
|
||||||
|
necessary: `(A,B)` and `(B,A)` are distinct rows to the database but the same
|
||||||
|
link to a person, and the UniqueConstraint only covers the stored direction.
|
||||||
|
|
||||||
|
**Scope, which is the part that actually matters.** A link is a pointer that
|
||||||
|
exposes the far issue's id, description, facility and status. Three surfaces
|
||||||
|
have to hold the line and only one of them is a real boundary:
|
||||||
|
|
||||||
|
_readable_links() filters what the panel RENDERS
|
||||||
|
link_search() scopes what the picker FINDS
|
||||||
|
add_link() re-checks on POST — the search is a convenience
|
||||||
|
|
||||||
|
If any one of them is unfiltered, a customer reads an issue at a facility they
|
||||||
|
hold no assignment to, simply because one of our staff linked it.
|
||||||
|
|
||||||
|
Cross-TENANT isolation is not tested here and cannot be: `issue_links` lives in
|
||||||
|
the tenant database and every query routes through RoutingSession, so an id
|
||||||
|
from another tenant does not resolve at all. See tests/test_tenant_isolation.py
|
||||||
|
for that layer. What follows is scope WITHIN one tenant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
with app.app_context():
|
||||||
|
from app import db
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
yield app.test_client()
|
||||||
|
db.session.remove()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Builders ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_n = {'i': 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _uniq():
|
||||||
|
_n['i'] += 1
|
||||||
|
return _n['i']
|
||||||
|
|
||||||
|
|
||||||
|
def _user(role='admin', **kw):
|
||||||
|
from app import db
|
||||||
|
from app.models.user import User
|
||||||
|
n = _uniq()
|
||||||
|
u = User(username=kw.pop('username', f'{role}{n}'),
|
||||||
|
full_name=f'{role.title()} {n}',
|
||||||
|
email=f'{role}{n}@example.com',
|
||||||
|
role=role, active=True, password_set=True)
|
||||||
|
u.set_password('pw-correct1')
|
||||||
|
for k, v in kw.items():
|
||||||
|
setattr(u, k, v)
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def _project(name=None):
|
||||||
|
from app import db
|
||||||
|
from app.models.project import Project
|
||||||
|
p = Project(name=name or f'Contract {_uniq()}', active=True)
|
||||||
|
db.session.add(p)
|
||||||
|
db.session.commit()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _facility(project=None, name=None):
|
||||||
|
from app import db
|
||||||
|
from app.models.facility import Facility
|
||||||
|
f = Facility(name=name or f'Facility {_uniq()}', active=True,
|
||||||
|
project_id=project.id if project else None)
|
||||||
|
db.session.add(f)
|
||||||
|
db.session.commit()
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _issue(facility=None, description='Test issue', **kw):
|
||||||
|
from app import db
|
||||||
|
from app.models.issue import Issue
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
i = Issue(facility_id=facility.id if facility else None,
|
||||||
|
description=description,
|
||||||
|
severity=kw.pop('severity', 'medium'),
|
||||||
|
status=kw.pop('status', 'open'),
|
||||||
|
reported_at=kw.pop('reported_at', now_eastern()))
|
||||||
|
for k, v in kw.items():
|
||||||
|
setattr(i, k, v)
|
||||||
|
db.session.add(i)
|
||||||
|
db.session.commit()
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
def _link(a, b, link_type='related'):
|
||||||
|
from app import db
|
||||||
|
from app.models.issue import IssueLink
|
||||||
|
link = IssueLink(issue_id=a.id, linked_issue_id=b.id, link_type=link_type)
|
||||||
|
db.session.add(link)
|
||||||
|
db.session.commit()
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_inspector(user, project):
|
||||||
|
from app import db
|
||||||
|
from app.models.inspector_assignment import InspectorAssignment
|
||||||
|
db.session.add(InspectorAssignment(user_id=user.id, project_id=project.id))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_customer(user, project):
|
||||||
|
from app import db
|
||||||
|
from app.models.project import CustomerAssignment
|
||||||
|
db.session.add(CustomerAssignment(user_id=user.id, project_id=project.id))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, user):
|
||||||
|
return client.post('/auth/login',
|
||||||
|
data={'username': user.username, 'password': 'pw-correct1'},
|
||||||
|
follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _link_count():
|
||||||
|
from app.models.issue import IssueLink
|
||||||
|
return IssueLink.query.count()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Model semantics ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_a_link_reads_differently_from_each_end(client):
|
||||||
|
"""The stored direction carries meaning for 'duplicate'."""
|
||||||
|
a, b = _issue(description='dupe'), _issue(description='original')
|
||||||
|
link = _link(a, b, 'duplicate')
|
||||||
|
|
||||||
|
assert link.label_for(a.id) == 'Duplicate of'
|
||||||
|
assert link.label_for(b.id) == 'Duplicated by'
|
||||||
|
assert link.other_issue(a.id).id == b.id
|
||||||
|
assert link.other_issue(b.id).id == a.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_related_reads_the_same_from_both_ends(client):
|
||||||
|
a, b = _issue(), _issue()
|
||||||
|
link = _link(a, b, 'related')
|
||||||
|
assert link.label_for(a.id) == 'Related to'
|
||||||
|
assert link.label_for(b.id) == 'Related to'
|
||||||
|
|
||||||
|
|
||||||
|
def test_one_row_appears_on_both_issues(client):
|
||||||
|
"""all_links() merges the two storage directions into one list."""
|
||||||
|
a, b = _issue(), _issue()
|
||||||
|
_link(a, b)
|
||||||
|
|
||||||
|
assert _link_count() == 1
|
||||||
|
assert len(a.all_links()) == 1
|
||||||
|
assert len(b.all_links()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_exists_between_is_direction_agnostic(client):
|
||||||
|
"""The UniqueConstraint only covers the stored direction; this covers both."""
|
||||||
|
from app.models.issue import IssueLink
|
||||||
|
|
||||||
|
a, b = _issue(), _issue()
|
||||||
|
_link(a, b)
|
||||||
|
|
||||||
|
assert IssueLink.exists_between(a.id, b.id)
|
||||||
|
assert IssueLink.exists_between(b.id, a.id)
|
||||||
|
assert not IssueLink.exists_between(a.id, _issue().id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_deleting_an_issue_removes_its_links_from_both_sides(client):
|
||||||
|
"""A surviving link would render a dead row on the other issue's page."""
|
||||||
|
from app import db
|
||||||
|
|
||||||
|
a, b, c = _issue(), _issue(), _issue()
|
||||||
|
_link(a, b) # a is the source
|
||||||
|
_link(c, a) # a is the target
|
||||||
|
assert _link_count() == 2
|
||||||
|
|
||||||
|
db.session.delete(a)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
assert _link_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Creating links through the route ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_admin_can_link_two_issues(client):
|
||||||
|
admin = _user('admin')
|
||||||
|
a, b = _issue(), _issue()
|
||||||
|
_login(client, admin)
|
||||||
|
|
||||||
|
client.post(f'/issues/{a.id}/links',
|
||||||
|
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
from app.models.issue import IssueLink
|
||||||
|
link = IssueLink.query.one()
|
||||||
|
assert (link.issue_id, link.linked_issue_id) == (a.id, b.id)
|
||||||
|
assert link.link_type == 'duplicate'
|
||||||
|
assert link.created_by == admin.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_leading_hash_is_accepted(client):
|
||||||
|
"""People type the issue number the way it is displayed."""
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
a, b = _issue(), _issue()
|
||||||
|
|
||||||
|
client.post(f'/issues/{a.id}/links',
|
||||||
|
data={'link_type': 'related', 'linked_issue_id': f'#{b.id}'},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
assert _link_count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_linking_does_not_touch_either_issue(client):
|
||||||
|
"""A link is PURELY NAVIGATIONAL — no status, SLA, assignee or follower."""
|
||||||
|
from app import db
|
||||||
|
from app.models.issue import Issue, IssueFollower
|
||||||
|
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
a = _issue(status='open', severity='high')
|
||||||
|
b = _issue(status='in_progress', severity='low')
|
||||||
|
before = [(i.id, i.status, i.severity, i.assigned_to, i.resolved_at,
|
||||||
|
i.sla_notified) for i in (a, b)]
|
||||||
|
|
||||||
|
client.post(f'/issues/{a.id}/links',
|
||||||
|
data={'link_type': 'duplicate', 'linked_issue_id': str(b.id)},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
# db.session.get, not Model.query.get — SQLAlchemy 2.x (CLAUDE.md rule 11).
|
||||||
|
after = [(i.id, i.status, i.severity, i.assigned_to, i.resolved_at,
|
||||||
|
i.sla_notified)
|
||||||
|
for i in (db.session.get(Issue, a.id), db.session.get(Issue, b.id))]
|
||||||
|
assert before == after
|
||||||
|
assert IssueFollower.query.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('payload', [
|
||||||
|
{'link_type': 'related', 'linked_issue_id': 'not-a-number'},
|
||||||
|
{'link_type': 'related', 'linked_issue_id': ''},
|
||||||
|
{'link_type': 'nonsense', 'linked_issue_id': '1'},
|
||||||
|
{'linked_issue_id': '1'},
|
||||||
|
])
|
||||||
|
def test_malformed_link_requests_are_rejected_without_error(client, payload):
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
a = _issue()
|
||||||
|
|
||||||
|
res = client.post(f'/issues/{a.id}/links', data=payload,
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert _link_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_issue_cannot_be_linked_to_itself(client):
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
a = _issue()
|
||||||
|
|
||||||
|
client.post(f'/issues/{a.id}/links',
|
||||||
|
data={'link_type': 'related', 'linked_issue_id': str(a.id)},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
assert _link_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_same_pair_cannot_be_linked_twice_in_either_direction(client):
|
||||||
|
"""The reverse direction is the case the UniqueConstraint cannot catch."""
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
a, b = _issue(), _issue()
|
||||||
|
|
||||||
|
client.post(f'/issues/{a.id}/links',
|
||||||
|
data={'link_type': 'related', 'linked_issue_id': str(b.id)},
|
||||||
|
follow_redirects=True)
|
||||||
|
client.post(f'/issues/{b.id}/links',
|
||||||
|
data={'link_type': 'duplicate', 'linked_issue_id': str(a.id)},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
assert _link_count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlinking_works_from_either_end(client):
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
a, b = _issue(), _issue()
|
||||||
|
link = _link(a, b)
|
||||||
|
|
||||||
|
# From the far end — the row is stored on a, deleted from b's page.
|
||||||
|
client.post(f'/issues/{b.id}/links/{link.id}/delete', follow_redirects=True)
|
||||||
|
|
||||||
|
assert _link_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cannot_delete_a_link_between_two_other_issues(client):
|
||||||
|
"""The link must actually touch the issue named in the URL."""
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
a, b, unrelated = _issue(), _issue(), _issue()
|
||||||
|
link = _link(a, b)
|
||||||
|
|
||||||
|
client.post(f'/issues/{unrelated.id}/links/{link.id}/delete',
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
assert _link_count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Permission ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('role', ['admin', 'director', 'auditor'])
|
||||||
|
def test_issue_managers_may_link(client, role):
|
||||||
|
_login(client, _user(role))
|
||||||
|
a, b = _issue(), _issue()
|
||||||
|
|
||||||
|
client.post(f'/issues/{a.id}/links',
|
||||||
|
data={'link_type': 'related', 'linked_issue_id': str(b.id)},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
assert _link_count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_assignee_may_link_their_own_issue(client):
|
||||||
|
"""_can_manage_links mirrors the page's can_edit, which includes the assignee."""
|
||||||
|
project = _project()
|
||||||
|
facility = _facility(project)
|
||||||
|
inspector = _user('inspector')
|
||||||
|
_assign_inspector(inspector, project)
|
||||||
|
|
||||||
|
a = _issue(facility=facility, assigned_to=inspector.id)
|
||||||
|
b = _issue(facility=facility)
|
||||||
|
|
||||||
|
_login(client, inspector)
|
||||||
|
client.post(f'/issues/{a.id}/links',
|
||||||
|
data={'link_type': 'related', 'linked_issue_id': str(b.id)},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
assert _link_count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_project_manager_may_not_link(client):
|
||||||
|
"""Widening this is a one-line change — but change view.html's can_edit too."""
|
||||||
|
_login(client, _user('project_manager'))
|
||||||
|
a, b = _issue(), _issue()
|
||||||
|
|
||||||
|
res = client.post(f'/issues/{a.id}/links',
|
||||||
|
data={'link_type': 'related', 'linked_issue_id': str(b.id)})
|
||||||
|
|
||||||
|
assert res.status_code == 403
|
||||||
|
assert _link_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_customer_may_not_link_even_on_their_own_facility(client):
|
||||||
|
project = _project()
|
||||||
|
facility = _facility(project)
|
||||||
|
customer = _user('customer')
|
||||||
|
_assign_customer(customer, project)
|
||||||
|
|
||||||
|
a = _issue(facility=facility)
|
||||||
|
b = _issue(facility=facility)
|
||||||
|
|
||||||
|
_login(client, customer)
|
||||||
|
res = client.post(f'/issues/{a.id}/links',
|
||||||
|
data={'link_type': 'related', 'linked_issue_id': str(b.id)})
|
||||||
|
|
||||||
|
assert res.status_code == 403
|
||||||
|
assert _link_count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Scope: the part that actually matters ────────────────────────────────────
|
||||||
|
|
||||||
|
def test_cannot_link_to_an_issue_outside_your_scope(client):
|
||||||
|
"""An inspector must not be able to attach another contract's issue."""
|
||||||
|
mine, theirs = _project('Mine'), _project('Theirs')
|
||||||
|
my_facility, their_facility = _facility(mine), _facility(theirs)
|
||||||
|
|
||||||
|
inspector = _user('inspector')
|
||||||
|
_assign_inspector(inspector, mine)
|
||||||
|
|
||||||
|
a = _issue(facility=my_facility, assigned_to=inspector.id)
|
||||||
|
out_of_scope = _issue(facility=their_facility, description='another contract')
|
||||||
|
|
||||||
|
_login(client, inspector)
|
||||||
|
res = client.post(f'/issues/{a.id}/links',
|
||||||
|
data={'link_type': 'related',
|
||||||
|
'linked_issue_id': str(out_of_scope.id)},
|
||||||
|
follow_redirects=True)
|
||||||
|
|
||||||
|
assert _link_count() == 0
|
||||||
|
# And the refusal must not confirm the issue exists.
|
||||||
|
assert 'another contract' not in res.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_panel_hides_a_link_whose_far_end_is_out_of_scope(client):
|
||||||
|
"""An admin can link across contracts. A customer at one end must still not
|
||||||
|
read the issue at the other."""
|
||||||
|
mine, theirs = _project('Mine'), _project('Theirs')
|
||||||
|
my_facility, their_facility = _facility(mine), _facility(theirs)
|
||||||
|
|
||||||
|
customer = _user('customer')
|
||||||
|
_assign_customer(customer, mine)
|
||||||
|
|
||||||
|
visible = _issue(facility=my_facility, description='my own issue')
|
||||||
|
hidden = _issue(facility=their_facility,
|
||||||
|
description='SECRET other customer issue')
|
||||||
|
_link(visible, hidden)
|
||||||
|
|
||||||
|
_login(client, customer)
|
||||||
|
body = client.get(f'/issues/{visible.id}').get_data(as_text=True)
|
||||||
|
|
||||||
|
assert 'my own issue' in body
|
||||||
|
assert 'SECRET other customer issue' not in body
|
||||||
|
assert f'/issues/{hidden.id}' not in body
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_search_is_scoped(client):
|
||||||
|
mine, theirs = _project('Mine'), _project('Theirs')
|
||||||
|
my_facility, their_facility = _facility(mine), _facility(theirs)
|
||||||
|
|
||||||
|
inspector = _user('inspector')
|
||||||
|
_assign_inspector(inspector, mine)
|
||||||
|
|
||||||
|
a = _issue(facility=my_facility, description='mine mine mine')
|
||||||
|
_issue(facility=my_facility, description='mine also')
|
||||||
|
_issue(facility=their_facility, description='mine but theirs')
|
||||||
|
|
||||||
|
_login(client, inspector)
|
||||||
|
results = client.get(f'/issues/{a.id}/link-search?q=mine').get_json()['results']
|
||||||
|
|
||||||
|
descriptions = {r['description'] for r in results}
|
||||||
|
assert 'mine also' in descriptions
|
||||||
|
assert 'mine but theirs' not in descriptions
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_search_excludes_self_and_already_linked(client):
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
a = _issue(description='alpha one')
|
||||||
|
b = _issue(description='alpha two')
|
||||||
|
_link(a, b)
|
||||||
|
|
||||||
|
ids = {r['id'] for r in
|
||||||
|
client.get(f'/issues/{a.id}/link-search?q=alpha').get_json()['results']}
|
||||||
|
|
||||||
|
assert a.id not in ids
|
||||||
|
assert b.id not in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_search_finds_by_issue_number(client):
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
a = _issue(description='alpha')
|
||||||
|
b = _issue(description='beta')
|
||||||
|
|
||||||
|
ids = {r['id'] for r in
|
||||||
|
client.get(f'/issues/{a.id}/link-search?q={b.id}').get_json()['results']}
|
||||||
|
|
||||||
|
assert b.id in ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_search_returns_nothing_for_an_unscoped_inspector(client):
|
||||||
|
"""Empty scope means no access to anything — rule 57's fail-closed default."""
|
||||||
|
inspector = _user('inspector') # no InspectorAssignment rows
|
||||||
|
a = _issue(facility=_facility(_project()), assigned_to=inspector.id)
|
||||||
|
|
||||||
|
_login(client, inspector)
|
||||||
|
res = client.get(f'/issues/{a.id}/link-search?q=a')
|
||||||
|
|
||||||
|
# Either the issue itself is unreachable (403) or the search finds nothing.
|
||||||
|
assert res.status_code == 403 or res.get_json()['results'] == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_the_panel_renders_both_directions(client):
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
original = _issue(description='the original')
|
||||||
|
dupe = _issue(description='the duplicate')
|
||||||
|
_link(dupe, original, 'duplicate')
|
||||||
|
|
||||||
|
on_dupe = client.get(f'/issues/{dupe.id}').get_data(as_text=True)
|
||||||
|
on_original = client.get(f'/issues/{original.id}').get_data(as_text=True)
|
||||||
|
|
||||||
|
assert 'Duplicate of' in on_dupe
|
||||||
|
assert f'/issues/{original.id}' in on_dupe
|
||||||
|
assert 'Duplicated by' in on_original
|
||||||
|
assert f'/issues/{dupe.id}' in on_original
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_panel_renders_with_no_links(client):
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
a = _issue()
|
||||||
|
|
||||||
|
body = client.get(f'/issues/{a.id}').get_data(as_text=True)
|
||||||
|
|
||||||
|
assert 'Linked Issues' in body
|
||||||
|
assert 'No linked issues.' in body
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"""
|
||||||
|
tests/test_scoped_pages_smoke.py
|
||||||
|
--------------------------------
|
||||||
|
Smoke coverage for the pages whose queries were rewritten during the database
|
||||||
|
optimization pass: the dashboard, the issues list, and the reports overview.
|
||||||
|
|
||||||
|
These routes had no request-level tests, so a query rewrite that produced
|
||||||
|
invalid SQL or a broken scope would only have surfaced in production — and in
|
||||||
|
MT that means in one tenant's production. Each test drives the real route
|
||||||
|
through the test client for a role that exercises a DIFFERENT branch of the
|
||||||
|
scoping code:
|
||||||
|
|
||||||
|
admin — unscoped branch
|
||||||
|
inspector — InspectorAssignment / get_inspector_scope() branch
|
||||||
|
customer — CustomerAssignment / get_customer_scope() branch
|
||||||
|
|
||||||
|
The assertions are deliberately about behaviour that must hold (status code,
|
||||||
|
scope isolation), not about markup.
|
||||||
|
|
||||||
|
What specifically is being guarded
|
||||||
|
----------------------------------
|
||||||
|
* `with_entities(*_ISSUE_CARD_COLS)` on the dashboard cards — a Row must keep
|
||||||
|
answering the same attribute names the tallies and sla_status() read.
|
||||||
|
* `facility_filter.isdigit()` on the issues list — `int('abc')` used to raise
|
||||||
|
ValueError and return 500 on a hand-edited query string.
|
||||||
|
* `scalar_subquery()` in reports `_scope_issue()` — including the empty case,
|
||||||
|
where `IN (empty subquery)` must match nothing rather than error.
|
||||||
|
* The collapsed scope gate in `issues.view()` (`_issue_readable_by`), which
|
||||||
|
the linked-issues panel now shares — see rule 110.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(app):
|
||||||
|
with app.app_context():
|
||||||
|
from app import db
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
yield app.test_client()
|
||||||
|
db.session.remove()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Builders ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_n = {'i': 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _uniq():
|
||||||
|
_n['i'] += 1
|
||||||
|
return _n['i']
|
||||||
|
|
||||||
|
|
||||||
|
def _user(role='admin'):
|
||||||
|
from app import db
|
||||||
|
from app.models.user import User
|
||||||
|
n = _uniq()
|
||||||
|
u = User(username=f'{role}{n}', full_name=f'{role.title()} {n}',
|
||||||
|
email=f'{role}{n}@example.com', role=role,
|
||||||
|
active=True, password_set=True)
|
||||||
|
u.set_password('pw-correct1')
|
||||||
|
db.session.add(u)
|
||||||
|
db.session.commit()
|
||||||
|
return u
|
||||||
|
|
||||||
|
|
||||||
|
def _project(name=None):
|
||||||
|
from app import db
|
||||||
|
from app.models.project import Project
|
||||||
|
p = Project(name=name or f'Contract {_uniq()}', active=True)
|
||||||
|
db.session.add(p)
|
||||||
|
db.session.commit()
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _facility(project=None, name=None):
|
||||||
|
from app import db
|
||||||
|
from app.models.facility import Facility
|
||||||
|
f = Facility(name=name or f'Facility {_uniq()}', active=True,
|
||||||
|
project_id=project.id if project else None)
|
||||||
|
db.session.add(f)
|
||||||
|
db.session.commit()
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
def _area(facility):
|
||||||
|
from app import db
|
||||||
|
from app.models.facility import Area
|
||||||
|
a = Area(facility_id=facility.id, name=f'Area {_uniq()}',
|
||||||
|
area_type='restroom')
|
||||||
|
db.session.add(a)
|
||||||
|
db.session.commit()
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def _issue(facility=None, area=None, severity='high', status='open',
|
||||||
|
hours_ago=1, **kw):
|
||||||
|
from app import db
|
||||||
|
from app.models.issue import Issue
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
issue = Issue(
|
||||||
|
facility_id=facility.id if facility else None,
|
||||||
|
area_id=area.id if area else None,
|
||||||
|
severity=severity,
|
||||||
|
description=kw.pop('description', 'test issue'),
|
||||||
|
status=status,
|
||||||
|
reported_at=now_eastern() - timedelta(hours=hours_ago),
|
||||||
|
)
|
||||||
|
for k, v in kw.items():
|
||||||
|
setattr(issue, k, v)
|
||||||
|
db.session.add(issue)
|
||||||
|
db.session.commit()
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def _template():
|
||||||
|
from app import db
|
||||||
|
from app.models.inspection import InspectionTemplate
|
||||||
|
t = InspectionTemplate(name=f'Template {_uniq()}', active=True,
|
||||||
|
form_schema=[])
|
||||||
|
db.session.add(t)
|
||||||
|
db.session.commit()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def _inspection(facility, inspector, template, status='completed',
|
||||||
|
overall_score=90.0, days_ago=5, **kw):
|
||||||
|
from app import db
|
||||||
|
from app.models.inspection import Inspection
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
insp = Inspection(
|
||||||
|
template_id=template.id,
|
||||||
|
facility_id=facility.id,
|
||||||
|
inspector_id=inspector.id,
|
||||||
|
inspection_date=now_eastern() - timedelta(days=days_ago),
|
||||||
|
overall_score=overall_score,
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
for k, v in kw.items():
|
||||||
|
setattr(insp, k, v)
|
||||||
|
db.session.add(insp)
|
||||||
|
db.session.commit()
|
||||||
|
return insp
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_inspector(user, project):
|
||||||
|
from app import db
|
||||||
|
from app.models.inspector_assignment import InspectorAssignment
|
||||||
|
db.session.add(InspectorAssignment(user_id=user.id, project_id=project.id))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_customer(user, project):
|
||||||
|
from app import db
|
||||||
|
from app.models.project import CustomerAssignment
|
||||||
|
db.session.add(CustomerAssignment(user_id=user.id, project_id=project.id))
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client, user):
|
||||||
|
return client.post('/auth/login',
|
||||||
|
data={'username': user.username, 'password': 'pw-correct1'},
|
||||||
|
follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dashboard ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('role', ['admin', 'director', 'project_manager', 'auditor'])
|
||||||
|
def test_dashboard_renders_for_staff_roles(client, role):
|
||||||
|
"""The card queries (severity / handler / SLA splits) build and run."""
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
facility = _facility(_project())
|
||||||
|
_issue(facility=facility, severity='critical', hours_ago=100)
|
||||||
|
_issue(facility=facility, severity='low', status='in_progress')
|
||||||
|
_issue(facility=facility, status='resolved', resolved_at=now_eastern())
|
||||||
|
|
||||||
|
_login(client, _user(role))
|
||||||
|
assert client.get('/').status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_renders_for_scoped_inspector(client):
|
||||||
|
project = _project()
|
||||||
|
inspector = _user('inspector')
|
||||||
|
_assign_inspector(inspector, project)
|
||||||
|
|
||||||
|
facility = _facility(project)
|
||||||
|
_issue(area=_area(facility), severity='high', hours_ago=50) # via area
|
||||||
|
_issue(facility=facility, severity='medium') # directly
|
||||||
|
|
||||||
|
_login(client, inspector)
|
||||||
|
assert client.get('/').status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_renders_for_inspector_with_no_assignments(client):
|
||||||
|
"""Strict scoping: no assignments must render an empty dashboard, not 500."""
|
||||||
|
_login(client, _user('inspector'))
|
||||||
|
assert client.get('/').status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_renders_for_customer(client):
|
||||||
|
project = _project()
|
||||||
|
customer = _user('customer')
|
||||||
|
_assign_customer(customer, project)
|
||||||
|
_issue(facility=_facility(project))
|
||||||
|
|
||||||
|
_login(client, customer)
|
||||||
|
assert client.get('/').status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ── Issues list ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_issues_list_renders(client):
|
||||||
|
_issue(facility=_facility(_project()))
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
assert client.get('/issues/').status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('query', [
|
||||||
|
'?facility_id=abc', # non-numeric — used to raise ValueError -> 500
|
||||||
|
'?facility_id=',
|
||||||
|
'?facility_id=999999', # numeric but nonexistent
|
||||||
|
'?contract_id=abc',
|
||||||
|
'?issue_id=abc',
|
||||||
|
'?severity=high&status=open',
|
||||||
|
'?handler_type=vendor',
|
||||||
|
'?unassigned=1',
|
||||||
|
'?sla=breached',
|
||||||
|
'?date_from=notadate&date_to=alsonot',
|
||||||
|
])
|
||||||
|
def test_issues_list_survives_malformed_filters(client, query):
|
||||||
|
"""A hand-edited or stale query string must never 500 the list."""
|
||||||
|
_issue(facility=_facility(_project()))
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
assert client.get('/issues/' + query).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_issues_list_scopes_a_customer_to_their_own_facilities(client):
|
||||||
|
"""The scope filter still isolates customers after the query rewrite."""
|
||||||
|
mine_project, other_project = _project('Contract A'), _project('Contract B')
|
||||||
|
customer = _user('customer')
|
||||||
|
_assign_customer(customer, mine_project)
|
||||||
|
|
||||||
|
_issue(facility=_facility(mine_project, 'Mine'), description='visible to me')
|
||||||
|
_issue(facility=_facility(other_project, 'Theirs'),
|
||||||
|
description='other customer only')
|
||||||
|
|
||||||
|
_login(client, customer)
|
||||||
|
body = client.get('/issues/').get_data(as_text=True)
|
||||||
|
assert 'visible to me' in body
|
||||||
|
assert 'other customer only' not in body
|
||||||
|
|
||||||
|
|
||||||
|
# ── Issue detail scope gate ──────────────────────────────────────────────────
|
||||||
|
# issues.view() had two inline scope blocks; they were collapsed into
|
||||||
|
# _issue_readable_by() so the linked-issues panel could reuse the same rule
|
||||||
|
# (rule 110). These pin the behaviour that gate must keep.
|
||||||
|
|
||||||
|
def test_issue_view_allows_an_inspector_inside_their_contract(client):
|
||||||
|
project = _project()
|
||||||
|
inspector = _user('inspector')
|
||||||
|
_assign_inspector(inspector, project)
|
||||||
|
issue = _issue(facility=_facility(project))
|
||||||
|
|
||||||
|
_login(client, inspector)
|
||||||
|
assert client.get(f'/issues/{issue.id}').status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_view_denies_an_inspector_outside_their_contract(client):
|
||||||
|
mine, theirs = _project(), _project()
|
||||||
|
inspector = _user('inspector')
|
||||||
|
_assign_inspector(inspector, mine)
|
||||||
|
issue = _issue(facility=_facility(theirs), description='not yours')
|
||||||
|
|
||||||
|
_login(client, inspector)
|
||||||
|
res = client.get(f'/issues/{issue.id}', follow_redirects=True)
|
||||||
|
assert 'not yours' not in res.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_view_denies_an_inspector_with_no_assignments(client):
|
||||||
|
"""Strict scoping: no assignments means no access, not full access."""
|
||||||
|
issue = _issue(facility=_facility(_project()), description='strictly scoped')
|
||||||
|
_login(client, _user('inspector'))
|
||||||
|
res = client.get(f'/issues/{issue.id}', follow_redirects=True)
|
||||||
|
assert 'strictly scoped' not in res.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_view_denies_a_customer_outside_their_contract(client):
|
||||||
|
mine, theirs = _project(), _project()
|
||||||
|
customer = _user('customer')
|
||||||
|
_assign_customer(customer, mine)
|
||||||
|
issue = _issue(facility=_facility(theirs), description='other customer only')
|
||||||
|
|
||||||
|
_login(client, customer)
|
||||||
|
res = client.get(f'/issues/{issue.id}', follow_redirects=True)
|
||||||
|
assert 'other customer only' not in res.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_issue_view_allows_a_customer_inside_their_contract(client):
|
||||||
|
project = _project()
|
||||||
|
customer = _user('customer')
|
||||||
|
_assign_customer(customer, project)
|
||||||
|
issue = _issue(facility=_facility(project), description='mine to read')
|
||||||
|
|
||||||
|
_login(client, customer)
|
||||||
|
res = client.get(f'/issues/{issue.id}')
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert 'mine to read' in res.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Reports overview ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_reports_overview_renders_for_admin(client):
|
||||||
|
project = _project()
|
||||||
|
_inspection(_facility(project), _user('inspector'), _template())
|
||||||
|
|
||||||
|
_login(client, _user('admin'))
|
||||||
|
assert client.get('/reports/').status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_reports_overview_inspector_scope_uses_subquery(client):
|
||||||
|
"""_scope_issue() now filters via a subquery instead of an id list."""
|
||||||
|
project = _project()
|
||||||
|
inspector = _user('inspector')
|
||||||
|
_assign_inspector(inspector, project)
|
||||||
|
|
||||||
|
facility = _facility(project)
|
||||||
|
insp = _inspection(facility, inspector, _template())
|
||||||
|
_issue(facility=facility, inspection_id=insp.id)
|
||||||
|
|
||||||
|
_login(client, inspector)
|
||||||
|
assert client.get('/reports/').status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_reports_overview_inspector_with_no_inspections(client):
|
||||||
|
"""IN (empty subquery) must match nothing rather than error."""
|
||||||
|
_login(client, _user('inspector'))
|
||||||
|
assert client.get('/reports/').status_code == 200
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
"""
|
||||||
|
tests/test_sla_candidate_query.py
|
||||||
|
---------------------------------
|
||||||
|
Equivalence test for the SQL prefilter in send_sla_alerts().
|
||||||
|
|
||||||
|
The cron used to read EVERY open issue and decide in Python which ones deserved
|
||||||
|
a notification. It now narrows to candidates in SQL first.
|
||||||
|
|
||||||
|
The contract is a CONSERVATIVE SUPERSET, not equality:
|
||||||
|
|
||||||
|
* Nothing the old loop would have notified may be missed. This is the safety
|
||||||
|
property — a miss is a silently unsent SLA alert, with nothing in any log
|
||||||
|
to say it did not happen.
|
||||||
|
* The SQL may return extra rows, because it deliberately does not replicate
|
||||||
|
the "already notified at_risk and still only at_risk" skip (that would mean
|
||||||
|
writing the per-severity deadline arithmetic a second time, in SQL). The
|
||||||
|
Python loop still applies that skip, so no extra notification is ever sent
|
||||||
|
— only a handful of extra rows are read.
|
||||||
|
|
||||||
|
The reference predicate below is the old loop's skip logic, written out.
|
||||||
|
|
||||||
|
Multi-tenant note: the cron runs once per tenant database, so this prefilter is
|
||||||
|
worth more here than in ST — the saving multiplies by the tenant count.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ctx(app):
|
||||||
|
with app.app_context():
|
||||||
|
from app import db
|
||||||
|
db.drop_all()
|
||||||
|
db.create_all()
|
||||||
|
yield app
|
||||||
|
db.session.remove()
|
||||||
|
|
||||||
|
|
||||||
|
def _candidate_query():
|
||||||
|
"""The prefilter exactly as send_sla_alerts() builds it."""
|
||||||
|
from app import db
|
||||||
|
from app.models.issue import Issue
|
||||||
|
from app.utils.sla import SLA_HOURS, AT_RISK_THRESHOLD
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
now = now_eastern()
|
||||||
|
age_clauses = [
|
||||||
|
db.and_(
|
||||||
|
Issue.severity == severity,
|
||||||
|
Issue.reported_at <= now - timedelta(hours=hours * AT_RISK_THRESHOLD),
|
||||||
|
)
|
||||||
|
for severity, hours in SLA_HOURS.items()
|
||||||
|
]
|
||||||
|
return Issue.query.filter(
|
||||||
|
Issue.status.in_(['open', 'in_progress', 'pending_verification']),
|
||||||
|
Issue.reported_at.isnot(None),
|
||||||
|
db.or_(Issue.sla_notified.is_(None), Issue.sla_notified != 'breached'),
|
||||||
|
db.or_(*age_clauses),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _old_loop_would_act(issue):
|
||||||
|
"""The pre-change Python logic: which rows survived to send a notification."""
|
||||||
|
from app.utils.sla import sla_status
|
||||||
|
|
||||||
|
if issue.status not in ('open', 'in_progress', 'pending_verification'):
|
||||||
|
return False
|
||||||
|
if issue.reported_at is None:
|
||||||
|
return False # would have raised TypeError — see the NULL test
|
||||||
|
status = sla_status(issue)
|
||||||
|
if status not in ('at_risk', 'breached'):
|
||||||
|
return False
|
||||||
|
already = issue.sla_notified
|
||||||
|
if already == 'breached':
|
||||||
|
return False
|
||||||
|
if already == 'at_risk' and status == 'at_risk':
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _add(severity, hours_ago, status='open', sla_notified=None):
|
||||||
|
from app import db
|
||||||
|
from app.models.issue import Issue
|
||||||
|
from app.utils.time_utils import now_eastern
|
||||||
|
|
||||||
|
issue = Issue(
|
||||||
|
severity=severity,
|
||||||
|
description='x',
|
||||||
|
status=status,
|
||||||
|
sla_notified=sla_notified,
|
||||||
|
reported_at=now_eastern() - timedelta(hours=hours_ago),
|
||||||
|
)
|
||||||
|
db.session.add(issue)
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def population(ctx):
|
||||||
|
"""One issue per interesting combination of severity / age / state."""
|
||||||
|
from app import db
|
||||||
|
from app.utils.sla import SLA_HOURS, AT_RISK_THRESHOLD
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for severity, window in SLA_HOURS.items():
|
||||||
|
at_risk_at = window * AT_RISK_THRESHOLD
|
||||||
|
rows += [
|
||||||
|
_add(severity, 0), # fresh -> ok
|
||||||
|
_add(severity, at_risk_at * 0.5), # halfway -> ok
|
||||||
|
_add(severity, at_risk_at + 1), # at risk
|
||||||
|
_add(severity, window + 1), # breached
|
||||||
|
# already-notified variants
|
||||||
|
_add(severity, at_risk_at + 1, sla_notified='at_risk'),
|
||||||
|
_add(severity, window + 1, sla_notified='at_risk'),
|
||||||
|
_add(severity, window + 1, sla_notified='breached'),
|
||||||
|
# non-actionable / other statuses
|
||||||
|
_add(severity, window + 1, status='resolved'),
|
||||||
|
_add(severity, window + 1, status='in_progress'),
|
||||||
|
_add(severity, window + 1, status='pending_verification'),
|
||||||
|
]
|
||||||
|
db.session.commit()
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefilter_never_misses_an_actionable_issue(population):
|
||||||
|
"""Safety property: every row the old loop notified is still selected."""
|
||||||
|
expected = {i.id for i in population if _old_loop_would_act(i)}
|
||||||
|
actual = {i.id for i in _candidate_query().all()}
|
||||||
|
|
||||||
|
assert expected, 'fixture built no actionable issues — test is vacuous'
|
||||||
|
assert expected <= actual, (
|
||||||
|
'the prefilter drops issues the old loop would have alerted on: '
|
||||||
|
f'{sorted(expected - actual)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefilter_extras_are_all_skipped_by_the_loop(population):
|
||||||
|
"""The extra rows the SQL lets through produce no extra notifications.
|
||||||
|
|
||||||
|
Each must be a row the Python loop independently skips, so the set of alerts
|
||||||
|
actually sent is unchanged.
|
||||||
|
"""
|
||||||
|
from app.utils.sla import sla_status
|
||||||
|
|
||||||
|
by_id = {i.id: i for i in population}
|
||||||
|
expected = {i.id for i in population if _old_loop_would_act(i)}
|
||||||
|
extras = {i.id for i in _candidate_query().all()} - expected
|
||||||
|
|
||||||
|
for iid in extras:
|
||||||
|
issue = by_id[iid]
|
||||||
|
assert not _old_loop_would_act(issue)
|
||||||
|
# and the only reason it is allowed through is the at_risk bookkeeping
|
||||||
|
assert issue.sla_notified == 'at_risk' and sla_status(issue) == 'at_risk', (
|
||||||
|
f'issue {iid} is an unexplained extra: status={issue.status} '
|
||||||
|
f'severity={issue.severity} sla_notified={issue.sla_notified} '
|
||||||
|
f'sla_status={sla_status(issue)}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_prefilter_excludes_the_bulk_of_open_issues(population):
|
||||||
|
"""The point of the change: most open issues are never read."""
|
||||||
|
from app.models.issue import Issue
|
||||||
|
|
||||||
|
total_open = Issue.query.filter(
|
||||||
|
Issue.status.in_(['open', 'in_progress', 'pending_verification'])
|
||||||
|
).count()
|
||||||
|
candidates = _candidate_query().count()
|
||||||
|
assert candidates < total_open
|
||||||
|
|
||||||
|
|
||||||
|
def test_null_reported_at_is_excluded_not_crashed(ctx):
|
||||||
|
"""reported_at is nullable and sla_status() raises TypeError on NULL.
|
||||||
|
|
||||||
|
Before the prefilter one such row aborted the whole cron run — and in MT it
|
||||||
|
would abort that tenant's run entirely. It must now be excluded in SQL.
|
||||||
|
"""
|
||||||
|
from app import db
|
||||||
|
from app.models.issue import Issue
|
||||||
|
from app.utils.sla import sla_status
|
||||||
|
|
||||||
|
issue = Issue(severity='high', description='x', status='open')
|
||||||
|
db.session.add(issue)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# The column carries default=now_eastern, so an ORM insert can never leave
|
||||||
|
# it NULL — the row has to be forced, which is exactly how a legacy or
|
||||||
|
# raw-SQL-inserted row would look.
|
||||||
|
db.session.execute(
|
||||||
|
db.text('UPDATE issues SET reported_at = NULL WHERE id = :i'),
|
||||||
|
{'i': issue.id},
|
||||||
|
)
|
||||||
|
db.session.commit()
|
||||||
|
db.session.expire(issue)
|
||||||
|
|
||||||
|
assert issue.reported_at is None
|
||||||
|
assert issue.id not in {i.id for i in _candidate_query().all()}
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
sla_status(issue) # confirms the crash the filter is avoiding
|
||||||
Reference in New Issue
Block a user