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.
74 lines
2.6 KiB
Python
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
|