Sep 4 - Add link relavant issues function

This commit is contained in:
2026-09-04 16:53:53 -04:00
parent d291dfc513
commit e9005b9b9c
20 changed files with 3208 additions and 72 deletions
+117 -3
View File
@@ -35,6 +35,7 @@
25. [Health Dashboard](#25-health-dashboard-health-on-panel)
26. [Coding Rules for AI Assistants](#26-coding-rules-for-ai-assistants)
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. |
| `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`. |
| `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. |
| `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`. |
@@ -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
```
### 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
```
@@ -565,7 +590,7 @@ The `DeviceRegistration` model and the duplicate `api_devices` blueprint were **
| `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) |
| `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) |
| `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 |
@@ -843,7 +868,7 @@ limiter = Limiter(
## 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`.
@@ -885,7 +910,7 @@ limiter = Limiter(
→ phase50_sched_acknowledged → phase51_external_inspector
→ phase52_user_ui_theme → phase53_knowledge_sort_order
→ 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 /
@@ -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.
#### 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):**
```bash
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. |
| 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. |
| 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. |
---
@@ -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
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**.
+2 -2
View File
@@ -285,8 +285,8 @@ def list_inspections():
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
limit = min(int(request.args.get('limit', 50)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
limit = min(request.args.get('limit', 50, type=int) or 50, 200)
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
query = Inspection.query
+2 -2
View File
@@ -152,8 +152,8 @@ def list_issues():
if user.role not in _ALLOWED_ROLES:
return api_error('Access denied', 403)
limit = min(int(request.args.get('limit', 100)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
limit = min(request.args.get('limit', 100, type=int) or 100, 200)
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
query = Issue.query
+1 -1
View File
@@ -73,7 +73,7 @@ def list_notifications():
"""
user = g.api_user
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():
q = Notification.query.filter_by(user_id=user.id, is_read=False)
+2 -2
View File
@@ -118,8 +118,8 @@ def list_scheduled():
return api_error('Access denied', 403)
try:
limit = min(int(request.args.get('limit', 100)), 200)
offset = max(int(request.args.get('offset', 0)), 0)
limit = min(request.args.get('limit', 100, type=int) or 100, 200)
offset = max(request.args.get('offset', 0, type=int) or 0, 0)
except (TypeError, ValueError):
return api_error('limit and offset must be integers', 400)
+8 -1
View File
@@ -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)
# ── Severity breakdown (derived from the same open_issues_all list) ───
+143
View File
@@ -43,6 +43,122 @@ class IssueFollower(db.Model):
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):
__tablename__ = 'issues'
@@ -118,10 +234,37 @@ class Issue(db.Model):
followers = db.relationship('IssueFollower', back_populates='issue',
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):
"""Return True if the given user is currently following this issue."""
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;
# this mapping exists so the mobile API can return a human-readable label
# without the client duplicating the strings. (phase43)
+12 -2
View File
@@ -17,6 +17,16 @@ bp = Blueprint('dashboard', __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('/dashboard')
@login_required
@@ -100,7 +110,7 @@ def index():
))
# 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)
severity_breakdown = {
'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:
all_open_issues = []
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_at_risk = sum(1 for i in all_open_issues if sla_status(i) == 'at_risk')
+4 -2
View File
@@ -254,7 +254,8 @@ def index():
q = q.filter(Inspection.status == status_filter)
if contract_filter.isdigit():
_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)
if facility_filter.isdigit():
@@ -1259,7 +1260,8 @@ def export_list_pdf():
q = q.filter(Inspection.status == status_filter)
if contract_filter.isdigit():
_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)
if facility_filter.isdigit():
+261 -18
View File
@@ -6,7 +6,7 @@ from flask import (Blueprint, render_template, redirect, url_for,
flash, request, current_app, jsonify, abort, Response)
from flask_login import login_required, current_user
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.user import User
from app.models.notification import (
@@ -71,6 +71,48 @@ class _SLAFilteredPage:
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):
"""Dropdown label for an assignee.
@@ -146,13 +188,14 @@ def export_list_pdf():
date_to_filter = ''
if contract_filter.isdigit():
_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_(
Issue.facility_id.in_(_contract_fids),
Area.facility_id.in_(_contract_fids),
)) if _contract_fids else q.filter(False)
if facility_filter:
if facility_filter.isdigit():
fid = int(facility_filter)
q = q.filter(db.or_(Issue.facility_id == fid, Area.facility_id == fid))
if reporter_filter.isdigit():
@@ -181,7 +224,7 @@ def export_list_pdf():
p = db.session.get(Project, int(contract_filter))
if p:
filter_parts.append(f'Contract: {p.name}')
if facility_filter:
if facility_filter.isdigit():
f = db.session.get(Facility, int(facility_filter))
if f:
filter_parts.append(f'Facility: {f.name}')
@@ -279,7 +322,8 @@ def index():
date_to_filter = ''
if contract_filter.isdigit():
_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_(
Issue.facility_id.in_(_contract_fids),
@@ -287,7 +331,7 @@ def index():
)) if _contract_fids else q.filter(False)
if reporter_filter.isdigit():
q = q.filter(Issue.reported_by == int(reporter_filter))
if facility_filter:
if facility_filter.isdigit():
fid = int(facility_filter)
q = q.filter(
db.or_(
@@ -325,8 +369,9 @@ def index():
# can render the following badge and inline unfollow button without an
# additional query per row.
followed_ids = {
f.issue_id
for f in IssueFollower.query.filter_by(user_id=current_user.id).all()
iid for (iid,) in
db.session.query(IssueFollower.issue_id)
.filter(IssueFollower.user_id == current_user.id).all()
}
# Facilities for the filter dropdown — scoped for inspectors/customers,
@@ -398,18 +443,14 @@ def view(issue_id):
if issue is None:
abort(404)
if current_user.is_inspector:
fids = get_inspector_scope(current_user)
facility = issue.resolved_facility
if not fids or not facility or facility.id not in fids:
# Scope gate — see _issue_readable_by(). This was two inline blocks that the
# linked-issues panel would have had to reproduce a third time; it is now
# one definition so the panel cannot end up more permissive than the page.
if not _issue_readable_by(issue, current_user):
flash('Access denied.', 'danger')
return redirect(url_for('issues.index'))
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':
# 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)
@@ -686,7 +727,209 @@ def view(issue_id):
form=form,
comments=comments,
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 ────────────────────────────────────────────────────────────────────
+19 -19
View File
@@ -74,23 +74,29 @@ def index():
if current_user.role in ('admin', 'director', 'project_manager'):
inspector_filter = request.args.get('inspector_id', type=int) or None
# Pre-compute inspection ID sets used by _scope_issue to avoid join conflicts.
inspector_inspection_ids = [] # own inspections (inspector role)
filter_inspection_ids = None # filtered inspector's inspections (admin/dir/PM)
# Scope issues by the relevant inspector's inspections, as a SUBQUERY rather
# than a materialised id list. The previous form pulled every inspection id
# 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:
inspector_inspection_ids = [
row[0] for row in
inspector_insp_subq = (
db.session.query(Inspection.id)
.filter(Inspection.inspector_id == current_user.id)
.all()
]
.scalar_subquery()
)
elif inspector_filter:
filter_inspection_ids = [
row[0] for row in
inspector_insp_subq = (
db.session.query(Inspection.id)
.filter(Inspection.inspector_id == inspector_filter)
.all()
]
.scalar_subquery()
)
def _scope_insp(q):
if is_inspector:
@@ -104,14 +110,8 @@ def index():
return q
def _scope_issue(q):
if is_inspector:
if not inspector_inspection_ids:
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 inspector_insp_subq is not None:
return q.filter(Issue.inspection_id.in_(inspector_insp_subq))
if customer_facility_ids is not None:
if not customer_facility_ids:
return q.filter(False)
+265
View File
@@ -227,6 +227,84 @@
</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 ───────────────────────────────────────────────────────── #}
<div class="card shadow-sm mb-4" id="comments-section">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
@@ -564,6 +642,77 @@
{% endif %}
</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'] %}
<div class="modal fade" id="deleteIssueModal" tabindex="-1" aria-labelledby="deleteIssueModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
@@ -619,6 +768,122 @@
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>
{% endblock %}
+22 -14
View File
@@ -18,6 +18,7 @@ that no facility-level scoping is required (full access applies).
"""
import logging
from app import db
from app.models.project import CustomerAssignment
from app.models.facility import Facility
@@ -43,30 +44,34 @@ def get_customer_scope(user) -> list[int] | None:
if user.role != 'customer':
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:
return []
# Separate direct facility assignments from project-level assignments
direct_facility_ids = {a.facility_id for a in assignments if a.facility_id}
project_ids = {a.project_id for a in assignments if not a.facility_id}
direct_facility_ids = {fac_id for _, fac_id in assignments if fac_id}
project_ids = {proj_id for proj_id, fac_id in assignments if not fac_id}
facility_ids = set(direct_facility_ids)
# 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:
project_facilities = (
Facility.query
.filter(
facility_ids.update(
fid for (fid,) in db.session.query(Facility.id).filter(
Facility.project_id.in_(project_ids),
Facility.active == True,
).all()
)
.all()
)
for f in project_facilities:
facility_ids.add(f.id)
logger.debug(
'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
# Column-only selects — see the note in get_customer_scope(). This runs on
# every scoped request for both inspector roles.
project_ids = [
a.project_id
for a in InspectorAssignment.query.filter_by(user_id=user.id).all()
pid for (pid,) in
db.session.query(InspectorAssignment.project_id)
.filter(InspectorAssignment.user_id == user.id).all()
]
if not project_ids:
return [] # strict: no assignments = no access
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.active == True,
).all()
+31 -4
View File
@@ -110,11 +110,38 @@ def send_sla_alerts():
logger = logging.getLogger(__name__)
# yield_per streams rows in batches of 100 rather than loading all open
# issues into memory at once. At current scale this is a no-op difference,
# but it prevents a memory spike if the issue count grows large.
# Narrow to actual CANDIDATES in SQL rather than reading every open issue
# and deciding in Python. This runs every 30 minutes forever, so the old
# 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(
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)
total_sent = 0
+36
View File
@@ -31,6 +31,42 @@ class Config:
SQLALCHEMY_TRACK_MODIFICATIONS = 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) ─────────────────────────────────────────────────
# Master switch. When False (default) the app behaves EXACTLY as the
# 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'))
+1161
View File
File diff suppressed because it is too large Load Diff
+501
View File
@@ -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
+341
View File
@@ -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
+199
View File
@@ -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