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:
ZdenekSrotyr 2026-05-05 00:01:22 +02:00
parent c3df03beb3
commit c53c1e1572
3 changed files with 97 additions and 1 deletions

View file

@ -602,6 +602,11 @@ async def corporate_memory(
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")))
# #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
# always visible to their author regardless of audience filtering;
# 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["downvotes"] = votes["downvotes"]
is_admin_view = is_user_admin(user["id"], conn)
ctx = _build_context(
request, user=user,
knowledge_items=items,
@ -621,7 +627,7 @@ async def corporate_memory(
domains=domains,
stats={"total": len(all_items), "approved": len([i for i in all_items if i.get("status") == "approved"])},
user_votes={},
is_km_admin=is_user_admin(user["id"], conn),
is_km_admin=is_admin_view,
user_contributions=user_contributions,
user_stats={"authored": len(user_contributions), "votes_given": 0},
# Template expects knowledge as object with .items and .total_pages
@ -630,6 +636,8 @@ async def corporate_memory(
current_page=1,
page=1,
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)

View file

@ -566,6 +566,23 @@
{% include '_app_header.html' %}
<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 -->
<div class="stats-bar">
<div class="stat-item">

View 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()