ixg_platform/apps/guides/models.py
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

74 lines
2.6 KiB
Python

from django.db import models
class GuidePage(models.Model):
class Audience(models.TextChoices):
SHARED = "shared", "Shared"
BUYER = "buyer", "Buyer"
OPS = "ops", "Operations"
ADMIN = "admin", "Administrator"
AUDIENCE_PREFIXES = (
("user-guides/index", Audience.SHARED),
("user-guides/buyer/", Audience.BUYER),
("user-guides/ops/", Audience.OPS),
("user-guides/admin/", Audience.ADMIN),
)
path = models.CharField(max_length=255, unique=True)
title = models.CharField(max_length=255)
audience = models.CharField(max_length=20, choices=Audience.choices)
content = models.TextField(blank=True, default="")
updated_at = models.DateTimeField()
synced_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ["path"]
def __str__(self):
return self.title
@property
def page_slug(self):
return self.path[len("user-guides/"):] if self.path.startswith("user-guides/") else self.path
def preview(self, max_chars=180):
for line in self.content.splitlines():
line = line.strip()
if not line or line.startswith(("#", "-", ">", "|", "*", "![", "```", "<")):
continue
text = line.replace("**", "").replace("`", "").replace("*", "").strip()
if text:
return text[:max_chars]
return ""
@classmethod
def map_audience(cls, path):
for prefix, audience in cls.AUDIENCE_PREFIXES:
if path == prefix or path.startswith(prefix):
return audience
return None
# 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