fix(ui): admin pending-review banner on /corporate-memory (#176)
The /corporate-memory page filters status IN ('approved','mandatory')
and showed no hint that pending items exist. With approval_mode set to
'review_queue' (the default in instance.yaml.example), every collection
run would silently funnel new items into the pending bucket where no
operator ever saw them.
For admins (is_km_admin), the page now renders a banner above the
stats bar:
N pending items awaiting review — review them at /corporate-memory/admin
Non-admins see no change (the route zeroes the count server-side
before passing to the template, so the hint is never leaked).
Tests: tests/test_corporate_memory_page.py.
This commit is contained in:
parent
c3df03beb3
commit
c53c1e1572
3 changed files with 97 additions and 1 deletions
|
|
@ -602,6 +602,11 @@ async def corporate_memory(
|
||||||
categories = sorted(set(i.get("category", "") for i in all_items if i.get("category")))
|
categories = sorted(set(i.get("category", "") for i in all_items if i.get("category")))
|
||||||
domains = sorted(set(i.get("domain", "") for i in all_items if i.get("domain")))
|
domains = sorted(set(i.get("domain", "") for i in all_items if i.get("domain")))
|
||||||
|
|
||||||
|
# #176: surface the pending review queue to admins. Without this the
|
||||||
|
# main page silently filtered status='pending' items and operators had
|
||||||
|
# no breadcrumb to /corporate-memory/admin.
|
||||||
|
pending_count = sum(1 for i in all_items if i.get("status") == "pending")
|
||||||
|
|
||||||
# "My contributions" — items the caller authored. Personal items are
|
# "My contributions" — items the caller authored. Personal items are
|
||||||
# always visible to their author regardless of audience filtering;
|
# always visible to their author regardless of audience filtering;
|
||||||
# this is the surface the user uses to mark/unmark `is_personal`.
|
# this is the surface the user uses to mark/unmark `is_personal`.
|
||||||
|
|
@ -612,6 +617,7 @@ async def corporate_memory(
|
||||||
item["upvotes"] = votes["upvotes"]
|
item["upvotes"] = votes["upvotes"]
|
||||||
item["downvotes"] = votes["downvotes"]
|
item["downvotes"] = votes["downvotes"]
|
||||||
|
|
||||||
|
is_admin_view = is_user_admin(user["id"], conn)
|
||||||
ctx = _build_context(
|
ctx = _build_context(
|
||||||
request, user=user,
|
request, user=user,
|
||||||
knowledge_items=items,
|
knowledge_items=items,
|
||||||
|
|
@ -621,7 +627,7 @@ async def corporate_memory(
|
||||||
domains=domains,
|
domains=domains,
|
||||||
stats={"total": len(all_items), "approved": len([i for i in all_items if i.get("status") == "approved"])},
|
stats={"total": len(all_items), "approved": len([i for i in all_items if i.get("status") == "approved"])},
|
||||||
user_votes={},
|
user_votes={},
|
||||||
is_km_admin=is_user_admin(user["id"], conn),
|
is_km_admin=is_admin_view,
|
||||||
user_contributions=user_contributions,
|
user_contributions=user_contributions,
|
||||||
user_stats={"authored": len(user_contributions), "votes_given": 0},
|
user_stats={"authored": len(user_contributions), "votes_given": 0},
|
||||||
# Template expects knowledge as object with .items and .total_pages
|
# Template expects knowledge as object with .items and .total_pages
|
||||||
|
|
@ -630,6 +636,8 @@ async def corporate_memory(
|
||||||
current_page=1,
|
current_page=1,
|
||||||
page=1,
|
page=1,
|
||||||
per_page=100,
|
per_page=100,
|
||||||
|
# #176: pending banner is admin-only.
|
||||||
|
pending_review_count=pending_count if is_admin_view else 0,
|
||||||
)
|
)
|
||||||
return templates.TemplateResponse(request, "corporate_memory.html", ctx)
|
return templates.TemplateResponse(request, "corporate_memory.html", ctx)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -566,6 +566,23 @@
|
||||||
{% include '_app_header.html' %}
|
{% include '_app_header.html' %}
|
||||||
|
|
||||||
<div class="container-memory">
|
<div class="container-memory">
|
||||||
|
{% if pending_review_count and pending_review_count > 0 %}
|
||||||
|
<!-- Admin-only review-queue banner (#176). Hidden when 0; non-admins
|
||||||
|
never see this because the route zeroes pending_review_count for
|
||||||
|
them server-side. -->
|
||||||
|
<div class="pending-review-banner" style="display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); margin-bottom: var(--space-4); background: #fef9c3; border: 1px solid #facc15; border-radius: var(--radius-md); color: #854d0e; font-size: var(--text-sm);">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12"/>
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
<strong>{{ pending_review_count }} pending item{{ 's' if pending_review_count != 1 else '' }}</strong>
|
||||||
|
awaiting review —
|
||||||
|
<a href="/corporate-memory/admin" style="color: #854d0e; text-decoration: underline; font-weight: var(--font-medium);">review them at /corporate-memory/admin</a>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<!-- Stats Bar -->
|
<!-- Stats Bar -->
|
||||||
<div class="stats-bar">
|
<div class="stats-bar">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
|
|
|
||||||
71
tests/test_corporate_memory_page.py
Normal file
71
tests/test_corporate_memory_page.py
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
"""GET /corporate-memory page rendering — pending banner contract.
|
||||||
|
|
||||||
|
The page used to filter `status IN ('approved','mandatory')` with no hint
|
||||||
|
that a `pending` review queue exists. Operators who configured
|
||||||
|
`approval_mode='review_queue'` saw an empty page after every collection
|
||||||
|
run and had no breadcrumb to /corporate-memory/admin. Closes one of
|
||||||
|
five defects in #176.
|
||||||
|
|
||||||
|
Contract:
|
||||||
|
- Admins see a banner when count(*) WHERE status='pending' > 0,
|
||||||
|
with a link to /corporate-memory/admin.
|
||||||
|
- Non-admins see no change to the page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def _auth(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_pending_item(item_id: str = "pending_item_1"):
|
||||||
|
from src.db import get_system_db
|
||||||
|
from src.repositories.knowledge import KnowledgeRepository
|
||||||
|
|
||||||
|
conn = get_system_db()
|
||||||
|
repo = KnowledgeRepository(conn)
|
||||||
|
repo.create(
|
||||||
|
id=item_id,
|
||||||
|
title=f"Pending review item {item_id}",
|
||||||
|
content="awaiting admin triage",
|
||||||
|
category="workflow",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingBannerForAdmins:
|
||||||
|
def test_admin_sees_pending_banner_when_pending_items_exist(self, seeded_app):
|
||||||
|
_seed_pending_item("p_admin_1")
|
||||||
|
c = seeded_app["client"]
|
||||||
|
token = seeded_app["admin_token"]
|
||||||
|
resp = c.get("/corporate-memory", headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.text
|
||||||
|
# Banner must mention the pending count and link to the admin queue.
|
||||||
|
assert "pending" in body.lower()
|
||||||
|
assert "/corporate-memory/admin" in body
|
||||||
|
|
||||||
|
def test_admin_no_banner_when_no_pending(self, seeded_app):
|
||||||
|
# Default seed has zero pending items.
|
||||||
|
c = seeded_app["client"]
|
||||||
|
token = seeded_app["admin_token"]
|
||||||
|
resp = c.get("/corporate-memory", headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.text
|
||||||
|
# The literal banner copy mentions "awaiting review"; absent when no
|
||||||
|
# pending items.
|
||||||
|
assert "awaiting review" not in body.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestNonAdminNeverSeesPendingBanner:
|
||||||
|
def test_analyst_does_not_see_banner_even_with_pending_items(self, seeded_app):
|
||||||
|
_seed_pending_item("p_no_admin_1")
|
||||||
|
c = seeded_app["client"]
|
||||||
|
token = seeded_app["analyst_token"]
|
||||||
|
resp = c.get("/corporate-memory", headers=_auth(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = resp.text
|
||||||
|
# Non-admin must not see the admin-only banner copy.
|
||||||
|
assert "awaiting review" not in body.lower()
|
||||||
Loading…
Reference in a new issue