ixg_platform/docs/USER_GUIDES_VIEWER_SPEC.md
ixgadmin ee4f7b5232 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.
2026-09-16 16:39:07 +00:00

212 lines
9 KiB
Markdown

# 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.