Help centre: portal-scoped guide audiences, and links that resolve

Guide visibility is now scoped by PORTAL first and role second:

- the buyer portal lists shared and buyer guides, even for an admin, because it
  is the buyer portal; admin guides never surface there
- the ops portal lists shared and ops guides, plus admin guides for an
  admin/superuser
- the detail view names its context object, so guide pages get a real browser
  title instead of a blank one

Link rewriting, so one page can serve both the wiki and the in-app help centre:

- absolute /en/user-guides/... links become the in-app help path
- other absolute /en/... links point at the wiki itself; the app hosts serve no
  /en/ routes, so leaving them untouched was a 404 for whoever clicked
- relative links keep their existing behaviour

Tests cover the new rewriting and the portal scoping rules. Also adds
docs/USER_GUIDES_VIEWER_SPEC.md, the spec this viewer was built to.
This commit is contained in:
ixgadmin 2026-09-16 16:39:07 +00:00
parent 8f1649aaa1
commit ee4f7b5232
5 changed files with 407 additions and 23 deletions

View file

@ -47,15 +47,44 @@ EXTENSIONS = ["tables", "fenced_code", "sane_lists"]
WIKI_IMAGE_PREFIXES = ("/buyer-guide/", "/ops-guide/")
# Links authored in the wiki are absolute: `/en/user-guides/<x>` is a guide that
# this help centre mirrors, while any other `/en/<x>` is a wiki-only page.
WIKI_GUIDE_PREFIX = "/en/user-guides/"
WIKI_PAGE_PREFIX = "/en/"
_IMAGE_SRC_RE = re.compile(r'(<img\b[^>]*?\bsrc=")([^"]*)(")', re.IGNORECASE)
_ANCHOR_HREF_RE = re.compile(r'(<a\b[^>]*?\bhref=")([^"]*)(")', re.IGNORECASE)
def _rewrite_absolute_anchor(prefix, href, suffix, help_root):
"""Point an absolute wiki link at somewhere that actually resolves.
These hrefs cannot be left alone. Every app host serves only its own
portal (/portal/, /ops/, /admin/) and has no `/en/` route, so an untouched
`/en/...` link is a 404 for whoever clicks it inside the help centre.
- `/en/user-guides/<x>` is mirrored in-app -> the in-app help path.
- any other `/en/<x>` exists only in the wiki -> the wiki public base.
"""
path = href.split("#", 1)[0].split("?", 1)[0]
if path.startswith(WIKI_GUIDE_PREFIX):
target = path[len(WIKI_GUIDE_PREFIX):].strip("/")
if target:
return f'{prefix}{help_root}{target}/{suffix}'
return f'{prefix}{help_root}{suffix}'
if path.startswith(WIKI_PAGE_PREFIX):
return f'{prefix}{settings.WIKI_PUBLIC_BASE}{path}{suffix}'
return None
def _rewrite_anchor(match, base_folder, help_root):
prefix, href, suffix = match.groups()
parsed = urlparse(href)
if parsed.scheme or href.startswith(("#", "/")):
if parsed.scheme or href.startswith("#"):
return match.group(0)
if href.startswith("/"):
rewritten = _rewrite_absolute_anchor(prefix, href, suffix, help_root)
return rewritten if rewritten is not None else match.group(0)
resolved = posixpath.normpath(posixpath.join(base_folder, href))
if resolved.startswith("user-guides/"):
suffix_path = resolved[len("user-guides/"):]

View file

@ -50,8 +50,25 @@ class GuidePage(models.Model):
return None
AUDIENCES_BY_ROLE = {
"buyer": {GuidePage.Audience.SHARED, GuidePage.Audience.BUYER},
"ops": {GuidePage.Audience.SHARED, GuidePage.Audience.OPS},
"admin": set(GuidePage.Audience.values),
# The help centre is scoped by PORTAL first, then by role. Which guides are
# listed depends on which portal you are standing in, not only on who you are:
# an admin browsing the buyer portal sees the buyer guides, because it is the
# buyer portal. The admin-only guides never surface there.
AUDIENCES_BY_PORTAL = {
"portal": frozenset({GuidePage.Audience.SHARED, GuidePage.Audience.BUYER}),
"ops": frozenset({GuidePage.Audience.SHARED, GuidePage.Audience.OPS}),
}
# Extra audiences unlocked by ROLE, per portal. Admin guides are ops-side
# material, so they are only ever added inside the ops portal.
ADMIN_EXTRA_AUDIENCES = {
"ops": frozenset({GuidePage.Audience.ADMIN}),
}
def allowed_audiences(portal, role=None, is_superuser=False):
"""Guide audiences visible in `portal` ("portal" or "ops") for this user."""
allowed = set(AUDIENCES_BY_PORTAL.get(portal, frozenset()))
if is_superuser or role == "admin":
allowed |= ADMIN_EXTRA_AUDIENCES.get(portal, frozenset())
return allowed

View file

@ -6,7 +6,7 @@ from django.test import TestCase, override_settings
from django.utils import timezone
from .markdown import render_markdown
from .models import GuidePage
from .models import GuidePage, allowed_audiences
User = get_user_model()
@ -60,14 +60,80 @@ class GuideAudienceFilterTests(TestCase):
self.assertNotContains(resp, self.buyer.title)
self.assertNotContains(resp, self.admin.title)
def test_admin_index_sees_all(self):
def test_admin_index_on_portal_lists_buyer_guides_only(self):
"""Portal scoping wins over role: the buyer portal shows buyer guides."""
self.client.force_login(self.admin_user)
for url in ("/portal/help/", "/ops/help/"):
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200, msg=url)
self.assertContains(resp, self.buyer.title, msg_prefix=url)
self.assertContains(resp, self.ops.title, msg_prefix=url)
self.assertContains(resp, self.admin.title, msg_prefix=url)
resp = self.client.get("/portal/help/")
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, self.shared.title)
self.assertContains(resp, self.buyer.title)
self.assertContains(resp, self.buyer2.title)
self.assertNotContains(resp, self.ops.title)
self.assertNotContains(resp, self.admin.title)
def test_admin_index_on_ops_lists_ops_and_admin_guides(self):
self.client.force_login(self.admin_user)
resp = self.client.get("/ops/help/")
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, self.shared.title)
self.assertContains(resp, self.ops.title)
self.assertContains(resp, self.admin.title)
self.assertNotContains(resp, self.buyer.title)
def test_superuser_follows_the_same_portal_scoping(self):
superuser = make_user("root@ixg.local", "admin")
superuser.is_superuser = True
superuser.save(update_fields=["is_superuser"])
self.client.force_login(superuser)
resp = self.client.get("/portal/help/")
self.assertNotContains(resp, self.ops.title)
resp = self.client.get("/ops/help/")
self.assertContains(resp, self.admin.title)
def test_ops_only_user_is_refused_at_the_portal_gate(self):
"""Only buyer/admin roles reach /portal/ at all (BuyerRequiredMixin).
So portal scoping here matters for exactly two kinds of viewer: real
buyers, and admins who can reach both portals.
"""
self.client.force_login(self.ops_user)
self.assertEqual(self.client.get("/portal/help/").status_code, 403)
def test_portal_audiences_helper(self):
"""The scoping rule itself, independent of view wiring."""
A = GuidePage.Audience
self.assertEqual(
allowed_audiences("portal", role="buyer"),
{A.SHARED, A.BUYER},
)
self.assertEqual(
allowed_audiences("portal", role="admin"),
{A.SHARED, A.BUYER},
msg="admin does not gain ops/admin guides inside the buyer portal",
)
self.assertEqual(
allowed_audiences("ops", role="ops"),
{A.SHARED, A.OPS},
)
self.assertEqual(
allowed_audiences("ops", role="admin"),
{A.SHARED, A.OPS, A.ADMIN},
)
self.assertEqual(
allowed_audiences("ops", role="buyer", is_superuser=True),
{A.SHARED, A.OPS, A.ADMIN},
msg="superuser is treated like the admin role",
)
def test_admin_cannot_reach_ops_guide_through_portal(self):
self.client.force_login(self.admin_user)
resp = self.client.get("/portal/help/ops/batches/")
self.assertEqual(resp.status_code, 404)
resp = self.client.get("/portal/help/admin/overview/")
self.assertEqual(resp.status_code, 404)
# ...but the same guides are reachable through the ops help centre
self.assertEqual(self.client.get("/ops/help/ops/batches/").status_code, 200)
self.assertEqual(self.client.get("/ops/help/admin/overview/").status_code, 200)
def test_buyer_cannot_access_ops_or_admin_guide(self):
self.client.force_login(self.buyer_user)
@ -143,15 +209,60 @@ class GuideMarkdownTests(TestCase):
)
self.assertIn('src="http://wiki.test/buyer-guide/buyer-dashboard.png"', html)
def test_external_and_anchor_links_left_alone(self):
def test_anchor_external_and_mailto_links_left_alone(self):
html = render_markdown(
"[Plan](/en/home/development/plan)\n\n[Top](#buyer-portal-guides)\n\n[Email](mailto:a@b.c)",
"[Top](#buyer-portal-guides)\n\n[Email](mailto:a@b.c)\n\n"
"[Elsewhere](https://example.com/x)",
"user-guides/index",
"/portal/help/",
)
self.assertIn('href="/en/home/development/plan"', html)
self.assertIn('href="#buyer-portal-guides"', html)
self.assertIn('href="mailto:a@b.c"', html)
self.assertIn('href="https://example.com/x"', html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_absolute_wiki_guide_link_rewritten_to_help_path(self):
"""`/en/user-guides/...` is mirrored in-app, so keep the reader in-app.
This is the link form the wiki itself needs (Wiki.js resolves a relative
link against the page's full path, so `walkthrough/signing-in` on the
page `user-guides/ops/walkthrough` 404s). Rewriting it here is what lets
one page serve both surfaces.
"""
html = render_markdown(
"[Step 3](/en/user-guides/ops/walkthrough/intakes)",
"user-guides/ops/walkthrough",
"/ops/help/",
)
self.assertIn('href="/ops/help/ops/walkthrough/intakes/"', html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_absolute_wiki_guide_link_to_an_index_page(self):
html = render_markdown(
"[Back to the index](/en/user-guides/index)",
"user-guides/buyer/getting-started",
"/portal/help/",
)
self.assertIn('href="/portal/help/index/"', html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_absolute_wiki_page_link_rewritten_to_the_wiki(self):
"""A wiki page with no in-app copy must point at the wiki.
The app hosts serve no /en/ routes, so leaving this href alone is a 404.
"""
html = render_markdown(
"[Development Plan](/en/home/development/plan)",
"user-guides/index",
"/ops/help/",
)
self.assertIn('href="http://wiki.test/en/home/development/plan"', html)
def test_absolute_app_paths_left_alone(self):
html = render_markdown(
"[Ops portal](/ops/batches/)", "user-guides/index", "/ops/help/"
)
self.assertIn('href="/ops/batches/"', html)
class FakeCursor:

View file

@ -4,19 +4,31 @@ from django.shortcuts import get_object_or_404
from django.views.generic import DetailView, ListView
from .markdown import render_markdown
from .models import AUDIENCES_BY_ROLE, GuidePage
from .models import GuidePage, allowed_audiences
class GuideAccessMixin:
"""Scope the help centre by PORTAL first, then by role.
Being an admin does not mean seeing every guide in every portal: the buyer
portal lists buyer guides (the audience of its readers), the ops portal
lists ops guides, and the admin guides only appear in the ops portal for
admin/superuser accounts. See apps.guides.models.allowed_audiences.
"""
def get_portal(self):
return getattr(self.request.resolver_match, "namespace", None) or "portal"
def get_allowed_audiences(self):
user = self.request.user
if user.is_superuser:
return set(GuidePage.Audience.values)
return AUDIENCES_BY_ROLE.get(getattr(user, "role", ""), set())
return allowed_audiences(
self.get_portal(),
role=getattr(user, "role", None),
is_superuser=user.is_superuser,
)
def get_help_root(self):
namespace = getattr(self.request.resolver_match, "namespace", None) or "portal"
return f"/{namespace}/help/"
return f"/{self.get_portal()}/help/"
class GuideIndexView(LoginRequiredMixin, GuideAccessMixin, ListView):
@ -34,12 +46,15 @@ class GuideIndexView(LoginRequiredMixin, GuideAccessMixin, ListView):
class GuideDetailView(LoginRequiredMixin, GuideAccessMixin, DetailView):
model = GuidePage
# the templates read {{ guide.title }}; without this the object arrives as
# "object" and every guide detail page renders a blank browser title
context_object_name = "guide"
def get_object(self, queryset=None):
path = f"user-guides/{self.kwargs['page_path']}"
page = get_object_or_404(GuidePage, path=path)
if page.audience not in self.get_allowed_audiences():
raise Http404("This guide is not available to your role.")
raise Http404("This guide is not available in this portal.")
return page
def get_context_data(self, **kwargs):

View file

@ -0,0 +1,212 @@
# User Guides Viewer (Help Centre) — Implementation Spec
Status: Ready for implementation (approved by IXG)
Author: Hermes (ixg-docs) — for OpenCode to implement
Repo: /root/work/ixg_platform (Django 5.2, project package `dsp_platform`, apps under `apps/`)
Date: 07 Aug 2026
---
## 1. Goal
A **read-only, role-gated help centre** inside the IXG platform that shows the
user guides authored in Wiki.js:
- Buyers: `/portal/help/` — the buyer guides
- Ops users: `/ops/help/` — the ops guides (+ admin guides for admin role)
- Admin users: can reach both portals (admin can already access `/portal/`
and `/ops/` per RBAC), so they see everything.
**The Wiki.js pages under `user-guides/` are the single source of truth.**
This feature is a one-way read/sync: the platform never writes to the wiki.
---
## 2. Source of truth (Wiki.js)
Pages live in the `wikijs` PostgreSQL database (same server, port 5432):
- Table `pages` — columns of interest: `path` (e.g. `user-guides/buyer/getting-started`, **no locale prefix**), `title`, `content` (markdown), `description`, `"isPublished"`, `"isPrivate"`, `"updatedAt"`.
- The `user-guides` tree:
```
user-guides/index (shared landing)
user-guides/buyer/* 7 buyer guides
user-guides/ops/* 9 ops guides
user-guides/admin/* 2 admin guides
```
Audience mapping (by path prefix):
| Prefix | Audience |
|--------|----------|
| `user-guides/index` | `shared` |
| `user-guides/buyer/` | `buyer` |
| `user-guides/ops/` | `ops` |
| `user-guides/admin/` | `admin` |
---
## 3. Architecture
### 3.1 New app `apps/guides`
Register in `INSTALLED_APPS` (settings.py). Contains:
**Model `GuidePage`**
| Field | Type | Notes |
|-------|------|-------|
| `path` | CharField, unique | wiki path, e.g. `user-guides/buyer/getting-started` |
| `title` | CharField | from wiki title |
| `audience` | CharField(choices: shared/buyer/ops/admin) | mapped from path |
| `content` | TextField | markdown body from wiki |
| `updated_at` | DateTimeField | wiki `updatedAt` |
| `synced_at` | DateTimeField | when we last synced |
**Management command `sync_guides`** (apps/guides/management/commands/sync_guides.py):
1. Connect to the `wikijs` DB alias (read-only).
2. `SELECT path, title, content, "updatedAt" FROM pages WHERE path LIKE 'user-guides/%' AND "isPublished" = true AND "isPrivate" = false`
3. Map audience from path prefix; upsert into `GuidePage` by `path`.
4. Delete `GuidePage` rows whose path no longer exists in the wiki (removed pages disappear from the help centre).
5. Print a summary (created / updated / removed counts).
**Views** — role-scoped querysets, both mounted under the two portal namespaces:
- `GuideIndexView` — lists guides the current user's role may see.
- `GuideDetailView` — renders one guide; 404 if the page's audience is not
allowed for the current user (defence in depth — the URL routes are already
role-gated by RBACMiddleware, but never rely on that alone).
**Scoping is by PORTAL first, then by role** (revised 16 Sep 2026). Which
guides are listed depends on the portal you are standing in, not only on who
you are: the buyer portal is for buyers, so it shows buyer guides even to an
admin. Admin-only guides are ops-side material and never appear in the buyer
portal. Implemented as `apps.guides.models.allowed_audiences(portal, role,
is_superuser)` — the single place the rule lives.
| Portal | Role | Allowed guide audiences |
|--------|------|-------------------------|
| `/portal/help/` | buyer | shared, buyer |
| `/portal/help/` | admin / superuser | shared, buyer (no change) |
| `/ops/help/` | ops | shared, ops |
| `/ops/help/` | admin / superuser | shared, ops, admin |
Note the role gate on the portal itself (`BuyerRequiredMixin` /
`OpsRequiredMixin`): only `buyer` + `admin` reach `/portal/`, only `ops` +
`admin` reach `/ops/`. So the two viewers portal scoping actually
distinguishes are real buyers and admins-who-can-see-everything.
**Markdown rendering** (in the view or a small helper `apps/guides/markdown.py`):
- `markdown.markdown(content, extensions=["tables", "fenced_code", "sane_lists"])`
- Sanitise with `bleach.clean(html, tags=[...], attributes=[...], protocols=["http","https","mailto"])` — allowed tags: `p br strong em ul ol li table thead tbody tr th td h1 h2 h3 h4 a code pre blockquote hr img span div`.
- **Link rewriting**: internal guide links in the wiki are relative markdown
links like `[Compliance guide](compliance)` or `[Compliance guide](compliance)`
(same-folder) — rewrite them to the current help root
(`/portal/help/<page-path-suffix>/` or `/ops/help/...`). Wiki image paths
starting with `/buyer-guide/` or `/ops-guide/` should be rewritten to the
wiki public base URL (setting `WIKI_PUBLIC_BASE`, default
`http://194.164.95.50`).
- Note: markdown-it-anchor headings are NOT available in this Wiki.js build,
so the guides avoid anchor links — no anchor handling needed.
### 3.2 Database access to `wikijs` (read-only)
- settings.py `DATABASES` gains a second alias `wikijs`, populated from env via
django-environ (same pattern as the default DB):
`WIKI_DB_NAME`, `WIKI_DB_HOST`, `WIKI_DB_PORT`, `WIKI_DB_USER`, `WIKI_DB_PASSWORD`.
- Create a **read-only** PostgreSQL role (run once as postgres, e.g. via
`docker exec postgres psql -U postgres`):
```sql
CREATE ROLE wikijs_ro LOGIN PASSWORD '<generate-a-strong-password>';
GRANT CONNECT ON DATABASE wikijs TO wikijs_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO wikijs_ro;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO wikijs_ro;
```
- Put the password ONLY in `config/secrets/env.production` (never in code,
wiki pages, or this spec).
### 3.3 URL wiring
- `apps/buyer/urls.py` (namespace `portal`):
`path("help/", GuideIndexView, name="guide_index")`
`path("help/<str:page_path>/", GuideDetailView, name="guide_detail")`
- `apps/ops/urls.py` (namespace `ops`): same two routes.
- Add a **Help** nav item to both base templates:
- `templates/portal/base_portal.html` sidebar (`bi bi-book` icon) → `{% url 'portal:guide_index' %}`
- `templates/ops/base_ops.html` sidebar (`bi bi-book` icon) → `{% url 'ops:guide_index' %}`
### 3.4 Templates (professional manual look)
- `templates/guides/`:
- `index_portal.html` — extends `portal/base_portal.html`, includes shared partial
- `index_ops.html` — extends `ops/base_ops.html`, includes shared partial
- `detail_portal.html` / `detail_ops.html` — same split
- Shared partials `_guide_list.html` (index: cards or a clean list with
titles + first paragraph preview) and `_guide_detail.html` (two-column:
left = guide list for the role, right = rendered content, with the page
title as heading and a breadcrumb/back link).
- Use existing Bootstrap + stat-card styling so it looks native to each portal.
- Empty state: "No guides available yet" when the list is empty.
---
## 4. Sync scheduling
Primary: host crontab (no app-level scheduler needed):
```
# every 6 hours
0 */6 * * * docker exec ixg-platform-app python manage.py sync_guides >> /var/log/ixg-guides-sync.log 2>&1
```
Manual refresh: `docker exec ixg-platform-app python manage.py sync_guides`.
Optional alternative (do NOT do both): a Celery beat task calling the same
command. Pick cron.
---
## 5. Acceptance criteria
1. `sync_guides` populates `GuidePage` from the wiki (verified by running it
and querying the model).
2. `/portal/help/` lists the 7 buyer guides + the shared landing page, and
**nothing else** — for every role that can reach it, including admin.
Opening one renders tables, headings and the embedded screenshots.
3. `/ops/help/` lists the 9 ops guides + the shared landing page; an admin
user also sees the 2 admin guides (12 rows).
4. A buyer user cannot see an ops/admin guide (route + audience filter; 404),
and an admin user cannot reach an ops guide through `/portal/help/` (404,
available via `/ops/help/` instead).
5. Internal guide links resolve within the help centre; images load from the
wiki public base.
6. The sync never writes to the `wikijs` database (read-only role enforced).
7. `python manage.py test` passes (add tests: audience filtering, markdown
sanitisation strips `<script>`, link rewriting, sync command mapping).
8. Existing portal/ops pages unchanged apart from the added Help nav item.
---
## 6. Out of scope
- Any write path back to the wiki.
- Public (unauthenticated) access, subdomains, or a separate docs app.
- Translating guides, theming, or search (nice-to-haves for later).
- The admin area (`/admin/`) itself — admins use `/ops/help/` for guides.
---
## 7. Implementation notes
- Deps already present: `markdown==3.7`, `bleach==6.2.0`, `psycopg2-binary`.
- Settings uses django-environ; env file `config/secrets/env.production`.
- Project runs in Docker (`ixg-platform-app`); migrations run via
`docker exec ixg-platform-app python manage.py migrate`.
- Keep the audience mapping table in one place (model method or constant) so
index/detail views share it.
- Do not hardcode the wiki DB password anywhere; env only.