From b661a94f98709daabce9ba4eb0aed9cf391c2ddf Mon Sep 17 00:00:00 2001 From: StyxX65 <150797939+StyxX65@users.noreply.github.com> Date: Mon, 22 Jun 2026 10:15:19 +0200 Subject: [PATCH] Restore user/group badges on DB-loaded result cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The card badge only rendered when f.account_name was set, and the group (role) badge was nested inside that same check. But save_item never persisted account_name — only account_id (a GUID) and user_role. Live SSE cards carried account_name so badges showed during a scan; now that the grid loads finalized scans from the DB, the gap is exposed and both badges vanish for earlier scans. - Persist account_name (migration 11 + save_item) so future scans show the user badge. Both M365 and Google cards already carry it. - _accountPill() in results.js drives the group badge off user_role alone (shows for legacy rows) and resolves a best-effort user label: account_name → S._allUsers (id/email) → email-style account_id → omit. Both card layouts share the one helper. Legacy rows still lack account_name (never captured), but now show the group badge and a resolved/email user label where possible. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 1 + gdpr_db.py | 6 ++++-- static/js/CLAUDE.md | 4 ++++ static/js/results.js | 30 ++++++++++++++++++++++++++++-- tests/test_db.py | 20 ++++++++++++++++++++ 5 files changed, 57 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 327c32b..f8120ea 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ All options live in the profile `options` dict and apply to **all three scan eng - **`get_sessions(limit=50, window_seconds=300)`** — groups `scans` rows by 300 s window. Groups built ascending, returned descending. `ref_scan_id` is the highest `scan_id` in each group. Do not change window size independently of `get_session_items`. - **`get_session_items(ref_scan_id=N)`** — anchors 300 s window to that scan's `started_at`. Window is **symmetric**: `started_at BETWEEN ref.started_at - 300 AND ref.started_at + 300`. Do not revert to a one-sided lower bound. - **`get_related_items(item_id, ref_scan_id, window_seconds=300)`** — self-joins `cpr_index` to find items sharing ≥1 CPR hash. Uses same 300 s symmetric window — do not change independently. +- **`account_name` (display name) is persisted** (migration 11) so DB-loaded cards show the user badge. Legacy rows predating it have `account_name=''` — the frontend `_accountPill` resolves a fallback and still shows the group badge from `user_role`. `save_item` must keep writing `card["account_name"]` (both M365 and Google cards carry it). - **Scans must be finalised or their items are invisible** — `get_session_items`, `get_open_items`, and `latest_scan_id` all filter on `finished_at IS NOT NULL`. The file scan finalises in a `finally`; M365 (`run_scan`) and Google (`_run_google_scan`) `return` early on abort, so each now calls `finish_scan` before that abort-return. A process kill (deploy/OOM/crash) mid-scan still strands a scan → **`finalize_orphan_scans()`** runs once at server startup (`gdpr_scanner.py` `__main__`, before the scheduler) and finalises every `finished_at IS NULL` scan (safe because nothing is scanning at boot). Do not add a scan-results query that ignores `finished_at` instead of fixing finalisation. - **`get_open_items()`** — returns every flagged item with **no action taken**, across **all** scans (not just the latest session window). "Open" = no `dispositions` row, or one whose `status='unreviewed'`. Because `flagged_items` PK is `(id, scan_id)`, the same item recurs per scan; the query dedupes by `id`, keeping the row from the highest finished `scan_id`. This powers the **default landing view** so items don't drop out of sight once a newer scan opens a fresh session. - **`GET /api/db/flagged`** — **with `?ref=N`** → `get_session_items(ref_scan_id=N)` (history mode); **without ref** → `get_open_items()` (default + viewer). Viewer scope enforcement applies to both. Do not change the no-ref `get_session_items()` default elsewhere (`export.py`, `scan_scheduler.py` still rely on latest-session for the current scan's report/email). diff --git a/gdpr_db.py b/gdpr_db.py index d823681..d5e250f 100644 --- a/gdpr_db.py +++ b/gdpr_db.py @@ -228,6 +228,7 @@ _MIGRATIONS: list[tuple[int, str]] = [ emailed INTEGER NOT NULL DEFAULT 0, error TEXT NOT NULL DEFAULT '' )"""), + (11, "ALTER TABLE flagged_items ADD COLUMN account_name TEXT NOT NULL DEFAULT ''"), ] @@ -329,8 +330,8 @@ class ScanDB: url, drive_id, size_kb, modified, cpr_count, risk, thumb_b64, thumb_mime, attachments, user_role, transfer_risk, special_category, face_count, exif_json, full_path, - email_count, phone_count, body_excerpt, scanned_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + email_count, phone_count, body_excerpt, account_name, scanned_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", ( card.get("id", ""), scan_id, @@ -357,6 +358,7 @@ class ScanDB: card.get("email_count", 0), card.get("phone_count", 0), card.get("body_excerpt", ""), + card.get("account_name", ""), now, ), ) diff --git a/static/js/CLAUDE.md b/static/js/CLAUDE.md index f8b8f5a..02e7689 100644 --- a/static/js/CLAUDE.md +++ b/static/js/CLAUDE.md @@ -49,6 +49,10 @@ Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — thos - **Re-scan diff** — items present in the previous session but absent from the current one are tagged `_resolved: true`, rendered with `.card-resolved` and a green ✓ badge, and NOT added to `S.flaggedData` (grid-only, cannot be bulk-selected or exported). - **Mode transitions** — `startScan()` calls `window.exitHistoryMode?.()` before clearing the grid. +## Card user/group badge — results.js + +- **`_accountPill(f)`** builds the account/role pill for both card layouts (list + grid). The **group badge is driven by `f.user_role`** (`student`/`staff`) alone, so it renders even with no display name — items from scans saved before `account_name` was persisted (DB migration 11) have only `user_role` + `account_id`. The user label resolves best-effort: `f.account_name` → `S._allUsers` match (by `id` or `email`) → email-style `account_id` → omit. Do not re-nest the role badge inside an `account_name` check (the old bug) — that hides the group badge for legacy items. Both layouts call `_accountPill(f)`; keep them sharing the one helper. + ## CPR cross-referencing — results.js - **`_loadRelated(f)`** — async; hides `#previewRelated` if `f.cpr_count` is 0, otherwise fetches `/api/db/related/?ref=N` and renders a clickable list with per-item shared-CPR badge. Called from `openPreview`. diff --git a/static/js/results.js b/static/js/results.js index 9b988c7..069313e 100644 --- a/static/js/results.js +++ b/static/js/results.js @@ -25,6 +25,31 @@ const SOURCE_BADGES = { smb: ['🌐', 'badge-smb', 'Network'], }; +// Build the user/group pill for a card. The group (role) badge is driven by +// user_role alone so it shows even when no display name is available — e.g. +// items from earlier scans saved before account_name was persisted. For those +// the user label is resolved best-effort from the loaded user list (by id or +// email), falling back to an email-style account_id. Returns '' when there is +// neither a label nor a role to show. +function _accountPill(f) { + const roleBadge = + f.user_role === 'student' ? '' + t('role_student', 'Elev') + '' : + f.user_role === 'staff' ? '' + t('role_staff', 'Ansat') + '' : ''; + let label = f.account_name || ''; + if (!label && f.account_id) { + const aid = String(f.account_id); + const u = (S._allUsers || []).find(function(u) { + return u.id === f.account_id || + (u.email && u.email.toLowerCase() === aid.toLowerCase()); + }); + if (u) label = u.displayName || ''; + else if (aid.includes('@')) label = aid; // an email is already human-readable + } + if (!label && !roleBadge) return ''; + const title = label || f.user_role || ''; + return ''; +} + function appendCard(f) { const search = document.getElementById('filterSearch').value.trim().toLowerCase(); const srcVal = document.getElementById('filterSource').value; @@ -61,6 +86,7 @@ function appendCard(f) { (f.source_type === 'smb' || f.source_type === 'sftp') ? _redactExts.has(_fileExt) : false ); const redactBtn = _redactable ? `` : ''; + const acctPill = _accountPill(f); if (S.isListView) { card.innerHTML = ` @@ -68,7 +94,7 @@ function appendCard(f) {
${esc(f.name)}
${f.size_kb} KB · ${esc(f.modified || '')}${f.folder ? ' · 📂 ' + esc(f.folder) : ''}
-
${esc(label)} ${esc(f.source || '')}${f.account_name ? ' · ' : ''}${f.transfer_risk === 'external-recipient' ? ' ⚠ Ext.' : f.transfer_risk ? ' 🔗' : ''}
+
${esc(label)} ${esc(f.source || '')}${acctPill ? ' · ' + acctPill : ''}${f.transfer_risk === 'external-recipient' ? ' ⚠ Ext.' : f.transfer_risk ? ' 🔗' : ''}
${f.cpr_count} CPR ${f.email_count > 0 ? ' ' : ''} @@ -84,7 +110,7 @@ function appendCard(f) {
${esc(f.name)}
${f.size_kb} KB · ${esc(f.modified || '')}
${f.folder ? `
📂 ${esc(f.folder)}
` : ''} -
${esc(label)}${f.account_name ? ' ' : ''}${f.transfer_risk === "external-recipient" ? ' ⚠ Ext.' : f.transfer_risk ? ' 🔗' : ''}
+
${esc(label)}${acctPill ? ' ' + acctPill : ''}${f.transfer_risk === "external-recipient" ? ' ⚠ Ext.' : f.transfer_risk ? ' 🔗' : ''}
${f.cpr_count} CPR${f.email_count > 0 ? ' ' : ''}${f.phone_count > 0 ? ' ' + f.phone_count + ' ' + t('m365_badge_phones', 'tlf.') + '' : ''}${f.face_count > 0 ? ' ' + f.face_count + ' ' + t('m365_badge_faces', f.face_count === 1 ? 'face' : 'faces') + '' : ''}${f.exif && f.exif.gps ? ' 🌍 GPS' : ''}${f._deleted ? ' 🗑 ' + t('delete_badge', 'Deleted') + '' : ''}${f._redacted ? ' ✏ ' + t('redact_badge', 'Redacted') + '' : ''}${f._resolved ? ' ✓ ' + t('history_resolved_badge', 'Resolved') + '' : ''}${f.overdue ? ' 🗓 Overdue' : ''} ${delBtn}${redactBtn}`; diff --git a/tests/test_db.py b/tests/test_db.py index 1b62862..f188775 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -313,3 +313,23 @@ class TestOrphanScanRecovery: self._start_unfinished_scan(tmp_db, "orphan-1") assert tmp_db.finalize_orphan_scans() == 1 assert tmp_db.finalize_orphan_scans() == 0 + + +# ───────────────────────────────────────────────────────────────────────────── +# account_name persistence (user/group badge data) +# ───────────────────────────────────────────────────────────────────────────── + +class TestAccountNamePersistence: + + def test_account_name_round_trips(self, tmp_db): + sid = tmp_db.begin_scan({"sources": ["email"], "user_ids": []}) + tmp_db.save_item(sid, _make_card(item_id="an-1")) # account_name="Test User" + tmp_db.finish_scan(sid, total_scanned=1) + + row = [r for r in tmp_db.get_open_items() if r["id"] == "an-1"][0] + assert row.get("account_name") == "Test User" + + def test_account_name_column_exists(self, tmp_db): + cols = [r[1] for r in tmp_db._connect().execute( + "PRAGMA table_info(flagged_items)").fetchall()] + assert "account_name" in cols