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.
9 KiB
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-guidestree:
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):
- Connect to the
wikijsDB alias (read-only). SELECT path, title, content, "updatedAt" FROM pages WHERE path LIKE 'user-guides/%' AND "isPublished" = true AND "isPrivate" = false- Map audience from path prefix; upsert into
GuidePagebypath. - Delete
GuidePagerows whose path no longer exists in the wiki (removed pages disappear from the help centre). - 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 (settingWIKI_PUBLIC_BASE, defaulthttp://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
DATABASESgains a second aliaswikijs, 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):
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(namespaceportal):path("help/", GuideIndexView, name="guide_index")path("help/<str:page_path>/", GuideDetailView, name="guide_detail")apps/ops/urls.py(namespaceops): same two routes.- Add a Help nav item to both base templates:
templates/portal/base_portal.htmlsidebar (bi bi-bookicon) →{% url 'portal:guide_index' %}templates/ops/base_ops.htmlsidebar (bi bi-bookicon) →{% url 'ops:guide_index' %}
3.4 Templates (professional manual look)
templates/guides/:index_portal.html— extendsportal/base_portal.html, includes shared partialindex_ops.html— extendsops/base_ops.html, includes shared partialdetail_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
sync_guidespopulatesGuidePagefrom the wiki (verified by running it and querying the model)./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./ops/help/lists the 9 ops guides + the shared landing page; an admin user also sees the 2 admin guides (12 rows).- 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). - Internal guide links resolve within the help centre; images load from the wiki public base.
- The sync never writes to the
wikijsdatabase (read-only role enforced). python manage.py testpasses (add tests: audience filtering, markdown sanitisation strips<script>, link rewriting, sync command mapping).- 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 viadocker 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.