diff --git a/apps/buyer/urls.py b/apps/buyer/urls.py index 686b28c..fa2e12c 100644 --- a/apps/buyer/urls.py +++ b/apps/buyer/urls.py @@ -12,4 +12,6 @@ urlpatterns = [ path('documents/', views.DocumentListView.as_view(), name='document_list'), path('subscription/', views.SubscriptionView.as_view(), name='subscription'), path('compliance/', views.ComplianceView.as_view(), name='compliance'), + path('help/', views.GuideIndexView.as_view(), name='guide_index'), + path('help//', views.GuideDetailView.as_view(), name='guide_detail'), ] diff --git a/apps/buyer/views.py b/apps/buyer/views.py index 57ff3c7..ed05b77 100644 --- a/apps/buyer/views.py +++ b/apps/buyer/views.py @@ -7,6 +7,8 @@ from apps.shipments.models import Shipment from apps.documents.models import Document from apps.esg.models import ESGFieldReport from apps.subscriptions.models import Subscription +from apps.guides.views import GuideDetailView as BaseGuideDetailView +from apps.guides.views import GuideIndexView as BaseGuideIndexView class BuyerRequiredMixin(UserPassesTestMixin): @@ -130,3 +132,11 @@ class ComplianceView(BuyerRequiredMixin, TemplateView): ).select_related("batch", "quality_approved_by").order_by("-published_at") ctx["eudr_cases"] = DueDiligenceCase.objects.select_related("batch", "approved_by").order_by("-updated_at") return ctx + + +class GuideIndexView(BuyerRequiredMixin, BaseGuideIndexView): + template_name = "guides/index_portal.html" + + +class GuideDetailView(BuyerRequiredMixin, BaseGuideDetailView): + template_name = "guides/detail_portal.html" diff --git a/apps/guides/__init__.py b/apps/guides/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/guides/admin.py b/apps/guides/admin.py new file mode 100644 index 0000000..54145b2 --- /dev/null +++ b/apps/guides/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from .models import GuidePage + + +@admin.register(GuidePage) +class GuidePageAdmin(admin.ModelAdmin): + list_display = ["path", "title", "audience", "updated_at", "synced_at"] + list_filter = ["audience"] + search_fields = ["path", "title"] + readonly_fields = ["synced_at"] diff --git a/apps/guides/apps.py b/apps/guides/apps.py new file mode 100644 index 0000000..fae26b4 --- /dev/null +++ b/apps/guides/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class GuidesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.guides" + verbose_name = "User Guides" diff --git a/apps/guides/management/__init__.py b/apps/guides/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/guides/management/commands/__init__.py b/apps/guides/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/guides/management/commands/sync_guides.py b/apps/guides/management/commands/sync_guides.py new file mode 100644 index 0000000..fefbca4 --- /dev/null +++ b/apps/guides/management/commands/sync_guides.py @@ -0,0 +1,75 @@ +from django.core.management.base import BaseCommand +from django.db import connections +from django.utils import timezone +from django.utils.dateparse import parse_datetime + +from apps.guides.models import GuidePage + +SELECT_PAGES_SQL = ( + 'SELECT path, title, content, "updatedAt" FROM pages ' + 'WHERE path LIKE %s AND "isPublished" = true AND "isPrivate" = false' +) + + +class Command(BaseCommand): + help = "One-way sync of Wiki.js user-guides pages into GuidePage (read-only on wikijs)." + + def handle(self, *args, **options): + created = 0 + updated = 0 + stale = [] + + existing = {page.path: page for page in GuidePage.objects.all()} + known_paths = set() + + with connections["wikijs"].cursor() as cursor: + cursor.execute(SELECT_PAGES_SQL, ["user-guides/%"]) + rows = cursor.fetchall() + + for path, title, content, updated_at_raw in rows: + audience = GuidePage.map_audience(path) + if audience is None: + continue + known_paths.add(path) + parsed = parse_datetime(updated_at_raw) if updated_at_raw else None + updated_at = parsed or timezone.now() + + page = existing.pop(path, None) + if page is None: + GuidePage.objects.create( + path=path, + title=title, + audience=audience, + content=content, + updated_at=updated_at, + ) + created += 1 + continue + + changed = ( + page.title != title + or page.content != content + or page.audience != audience + or page.updated_at != updated_at + ) + if changed: + page.title = title + page.content = content + page.audience = audience + page.updated_at = updated_at + page.save(update_fields=["title", "content", "audience", "updated_at", "synced_at"]) + updated += 1 + + removed = 0 + for path in existing: + if path not in known_paths: + stale.append(path) + if stale: + removed = GuidePage.objects.filter(path__in=stale).delete()[0] + + self.stdout.write( + self.style.SUCCESS( + f"sync_guides complete: {created} created, {updated} updated, {removed} removed." + ) + ) + self.stdout.write(f"Total GuidePage rows: {GuidePage.objects.count()}.") diff --git a/apps/guides/markdown.py b/apps/guides/markdown.py new file mode 100644 index 0000000..28a1bbe --- /dev/null +++ b/apps/guides/markdown.py @@ -0,0 +1,83 @@ +import posixpath +import re +from urllib.parse import urlparse + +import bleach +from django.conf import settings +from markdown import markdown + +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", +] + +ALLOWED_ATTRIBUTES = { + "a": ["href", "title"], + "img": ["src", "alt", "title"], + "th": ["align"], + "td": ["align"], +} + +ALLOWED_PROTOCOLS = ["http", "https", "mailto"] + +EXTENSIONS = ["tables", "fenced_code", "sane_lists"] + +WIKI_IMAGE_PREFIXES = ("/buyer-guide/", "/ops-guide/") + +_IMAGE_SRC_RE = re.compile(r'(]*?\bsrc=")([^"]*)(")', re.IGNORECASE) +_ANCHOR_HREF_RE = re.compile(r'(]*?\bhref=")([^"]*)(")', re.IGNORECASE) + + +def _rewrite_anchor(match, base_folder, help_root): + prefix, href, suffix = match.groups() + parsed = urlparse(href) + if parsed.scheme or href.startswith(("#", "/")): + return match.group(0) + resolved = posixpath.normpath(posixpath.join(base_folder, href)) + if resolved.startswith("user-guides/"): + suffix_path = resolved[len("user-guides/"):] + return f'{prefix}{help_root}{suffix_path}/{suffix}' + return match.group(0) + + +def _rewrite_image(match): + prefix, src, suffix = match.groups() + if src.startswith(WIKI_IMAGE_PREFIXES): + return f'{prefix}{settings.WIKI_PUBLIC_BASE}{src}{suffix}' + return match.group(0) + + +def render_markdown(content, page_path, help_root): + html = markdown(content or "", extensions=EXTENSIONS) + base_folder = posixpath.dirname(page_path) + html = _ANCHOR_HREF_RE.sub(lambda m: _rewrite_anchor(m, base_folder, help_root), html) + html = _IMAGE_SRC_RE.sub(_rewrite_image, html) + return bleach.clean( + html, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=ALLOWED_PROTOCOLS, + ) diff --git a/apps/guides/migrations/0001_initial.py b/apps/guides/migrations/0001_initial.py new file mode 100644 index 0000000..5359593 --- /dev/null +++ b/apps/guides/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.12 on 2026-08-07 14:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='GuidePage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('path', models.CharField(max_length=255, unique=True)), + ('title', models.CharField(max_length=255)), + ('audience', models.CharField(choices=[('shared', 'Shared'), ('buyer', 'Buyer'), ('ops', 'Operations'), ('admin', 'Administrator')], max_length=20)), + ('content', models.TextField(blank=True, default='')), + ('updated_at', models.DateTimeField()), + ('synced_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['path'], + }, + ), + ] diff --git a/apps/guides/migrations/__init__.py b/apps/guides/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/guides/models.py b/apps/guides/models.py new file mode 100644 index 0000000..c9e3c35 --- /dev/null +++ b/apps/guides/models.py @@ -0,0 +1,57 @@ +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 + + +AUDIENCES_BY_ROLE = { + "buyer": {GuidePage.Audience.SHARED, GuidePage.Audience.BUYER}, + "ops": {GuidePage.Audience.SHARED, GuidePage.Audience.OPS}, + "admin": set(GuidePage.Audience.values), +} diff --git a/apps/guides/tests.py b/apps/guides/tests.py new file mode 100644 index 0000000..9ddd2f2 --- /dev/null +++ b/apps/guides/tests.py @@ -0,0 +1,216 @@ +from unittest import mock + +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.test import TestCase, override_settings +from django.utils import timezone + +from .markdown import render_markdown +from .models import GuidePage + +User = get_user_model() + + +def make_user(email, role): + return User.objects.create_user( + email=email, password="pass1234", role=role, + full_name=email.split("@")[0], is_staff=True, + ) + + +def make_page(path, content="# Heading\n\nBody text", title=None): + return GuidePage.objects.create( + path=path, + title=title or path.rsplit("/", 1)[-1], + audience=GuidePage.map_audience(path), + content=content, + updated_at=timezone.now(), + ) + + +class GuideAudienceFilterTests(TestCase): + def setUp(self): + self.shared = make_page("user-guides/index", "# User Guides\n\nWelcome.", title="Landing Page") + self.buyer = make_page("user-guides/buyer/getting-started", title="Getting Started Guide") + self.buyer2 = make_page("user-guides/buyer/dashboard", title="Dashboard Guide") + self.ops = make_page("user-guides/ops/batches", title="Ops Batches Guide") + self.admin = make_page("user-guides/admin/overview", title="Admin Overview Guide") + self.buyer_user = make_user("buyer@ixg.local", "buyer") + self.ops_user = make_user("ops@ixg.local", "ops") + self.admin_user = make_user("admin@ixg.local", "admin") + + def test_buyer_index_shows_shared_and_buyer_only(self): + self.client.force_login(self.buyer_user) + resp = self.client.get("/portal/help/") + self.assertEqual(resp.status_code, 200) + content = resp.content.decode() + 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_ops_index_shows_shared_and_ops_only(self): + self.client.force_login(self.ops_user) + resp = self.client.get("/ops/help/") + self.assertEqual(resp.status_code, 200) + content = resp.content.decode() + self.assertContains(resp, self.shared.title) + self.assertContains(resp, self.ops.title) + self.assertNotContains(resp, self.buyer.title) + self.assertNotContains(resp, self.admin.title) + + def test_admin_index_sees_all(self): + 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) + + def test_buyer_cannot_access_ops_or_admin_guide(self): + self.client.force_login(self.buyer_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) + + def test_ops_cannot_access_buyer_guide(self): + self.client.force_login(self.ops_user) + resp = self.client.get("/ops/help/buyer/getting-started/") + self.assertEqual(resp.status_code, 404) + + def test_buyer_can_access_buyer_guide(self): + self.client.force_login(self.buyer_user) + resp = self.client.get("/portal/help/buyer/getting-started/") + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, "alert('x') world", + "user-guides/buyer/test", + "/portal/help/", + ) + self.assertNotIn("/', views.COAReviewDetailView.as_view(), name='coa_review_detail'), path('eudr/', views.EUDRCaseListView.as_view(), name='eudr_list'), path('eudr//', views.EUDRCaseDetailView.as_view(), name='eudr_detail'), + path('help/', views.GuideIndexView.as_view(), name='guide_index'), + path('help//', views.GuideDetailView.as_view(), name='guide_detail'), ] diff --git a/apps/ops/views.py b/apps/ops/views.py index 0ad93e5..8b19e0f 100644 --- a/apps/ops/views.py +++ b/apps/ops/views.py @@ -13,6 +13,8 @@ from apps.documents.models import Document, CertificateOfAnalysis from apps.esg.models import ESGFieldReport from apps.notifications.models import Notification from apps.compliance.models import DueDiligenceCase +from apps.guides.views import GuideDetailView as BaseGuideDetailView +from apps.guides.views import GuideIndexView as BaseGuideIndexView class OpsRequiredMixin(UserPassesTestMixin): @@ -370,3 +372,11 @@ class EUDRCaseDetailView(OpsRequiredMixin, DetailView): except ValueError as exc: messages.error(request, str(exc)) return redirect(self.request.path) + + +class GuideIndexView(OpsRequiredMixin, BaseGuideIndexView): + template_name = 'guides/index_ops.html' + + +class GuideDetailView(OpsRequiredMixin, BaseGuideDetailView): + template_name = 'guides/detail_ops.html' diff --git a/dsp_platform/settings.py b/dsp_platform/settings.py index b17181a..aa8359d 100644 --- a/dsp_platform/settings.py +++ b/dsp_platform/settings.py @@ -46,6 +46,7 @@ INSTALLED_APPS = [ "apps.notifications", "apps.ops", "apps.buyer", + "apps.guides", "encrypted_model_fields", ] @@ -89,7 +90,15 @@ DATABASES = { "PASSWORD": env("DB_PASSWORD", default=""), "HOST": env("DB_HOST", default="127.0.0.1"), "PORT": env.int("DB_PORT", default=5432), - } + }, + "wikijs": { + "ENGINE": "django.db.backends.postgresql", + "NAME": env("WIKI_DB_NAME", default="wikijs"), + "USER": env("WIKI_DB_USER", default=""), + "PASSWORD": env("WIKI_DB_PASSWORD", default=""), + "HOST": env("WIKI_DB_HOST", default="127.0.0.1"), + "PORT": env.int("WIKI_DB_PORT", default=5432), + }, } AUTH_PASSWORD_VALIDATORS = [ @@ -106,6 +115,8 @@ STATIC_ROOT = BASE_DIR / "staticfiles" MEDIA_URL = "/media/" MEDIA_ROOT = BASE_DIR / "media" +WIKI_PUBLIC_BASE = env("WIKI_PUBLIC_BASE", default="http://194.164.95.50") + DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" AUTH_USER_MODEL = "users.User" diff --git a/templates/guides/_guide_detail.html b/templates/guides/_guide_detail.html new file mode 100644 index 0000000..7014449 --- /dev/null +++ b/templates/guides/_guide_detail.html @@ -0,0 +1,28 @@ + + +
+
+
+
Guides
+ +
+
+
+
{{ guide_html|safe }}
+
+
diff --git a/templates/guides/_guide_list.html b/templates/guides/_guide_list.html new file mode 100644 index 0000000..0b6e119 --- /dev/null +++ b/templates/guides/_guide_list.html @@ -0,0 +1,26 @@ + + +{% if guides %} +
+ {% for guide in guides %} +
+
+
+
{{ guide.title }}
+ {{ guide.get_audience_display }} +
+ {% if guide.preview %} +

{{ guide.preview }}

+ {% endif %} +
+
+ {% endfor %} +
+{% else %} +
+

No guides available yet.

+
+{% endif %} diff --git a/templates/guides/detail_ops.html b/templates/guides/detail_ops.html new file mode 100644 index 0000000..f69f87a --- /dev/null +++ b/templates/guides/detail_ops.html @@ -0,0 +1,5 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}{{ guide.title }} - IXG Operations{% endblock %} +{% block content %} +{% include 'guides/_guide_detail.html' %} +{% endblock %} diff --git a/templates/guides/detail_portal.html b/templates/guides/detail_portal.html new file mode 100644 index 0000000..d90408f --- /dev/null +++ b/templates/guides/detail_portal.html @@ -0,0 +1,5 @@ +{% extends 'portal/base_portal.html' %} +{% block title %}{{ guide.title }} - IXG Buyer Portal{% endblock %} +{% block content %} +{% include 'guides/_guide_detail.html' %} +{% endblock %} diff --git a/templates/guides/index_ops.html b/templates/guides/index_ops.html new file mode 100644 index 0000000..bd37cd7 --- /dev/null +++ b/templates/guides/index_ops.html @@ -0,0 +1,5 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}Help Centre - IXG Operations{% endblock %} +{% block content %} +{% include 'guides/_guide_list.html' %} +{% endblock %} diff --git a/templates/guides/index_portal.html b/templates/guides/index_portal.html new file mode 100644 index 0000000..9fafb69 --- /dev/null +++ b/templates/guides/index_portal.html @@ -0,0 +1,5 @@ +{% extends 'portal/base_portal.html' %} +{% block title %}Help Centre - IXG Buyer Portal{% endblock %} +{% block content %} +{% include 'guides/_guide_list.html' %} +{% endblock %} diff --git a/templates/ops/base_ops.html b/templates/ops/base_ops.html index 905560c..d92e5a9 100644 --- a/templates/ops/base_ops.html +++ b/templates/ops/base_ops.html @@ -23,6 +23,11 @@ body { font-size: .875rem; background: #f5f6fa; } .page-header { margin-bottom: 1.5rem; } .page-header h2 { font-weight: 600; } .filter-bar { background: #fff; border-radius: .5rem; padding: 1rem; border: 1px solid #e2e8f0; margin-bottom: 1.5rem; } +.guide-content img { max-width: 100%; height: auto; border-radius: .375rem; } +.guide-content table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; } +.guide-content th, .guide-content td { border: 1px solid #e2e8f0; padding: .5rem .75rem; } +.guide-content pre { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: .375rem; padding: .75rem; } +.guide-content blockquote { border-left: 4px solid #a7f3d0; padding-left: 1rem; color: #475569; } @@ -40,6 +45,7 @@ body { font-size: .875rem; background: #f5f6fa; } + diff --git a/templates/portal/base_portal.html b/templates/portal/base_portal.html index a1680dd..5609ee9 100644 --- a/templates/portal/base_portal.html +++ b/templates/portal/base_portal.html @@ -23,6 +23,11 @@ body { font-size: .875rem; background: #f0fdf4; } .page-header { margin-bottom: 1.5rem; } .page-header h2 { font-weight: 600; } .filter-bar { background: #fff; border-radius: .5rem; padding: 1rem; border: 1px solid #e2e8f0; margin-bottom: 1.5rem; } +.guide-content img { max-width: 100%; height: auto; border-radius: .375rem; } +.guide-content table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; } +.guide-content th, .guide-content td { border: 1px solid #e2e8f0; padding: .5rem .75rem; } +.guide-content pre { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: .375rem; padding: .75rem; } +.guide-content blockquote { border-left: 4px solid #a7f3d0; padding-left: 1rem; color: #475569; } @@ -36,6 +41,7 @@ body { font-size: .875rem; background: #f0fdf4; } +