Finish the wiki root move, zone map view and spec approval

- Follow home/user-guides/ through one GUIDE_PATH_PREFIX constant.
- Add a read-only Origin Zone detail view with the map as the main panel.
- Name due diligence explicitly and add its section headings.
- Add admin-only, four-eyes specification approval (portal and API).
- Empty the New-zone geometry placeholder instead of showing null.
This commit is contained in:
ixgadmin 2026-10-27 17:41:08 +00:00
parent ef6f217e45
commit d74f0814da
22 changed files with 663 additions and 89 deletions

View file

@ -0,0 +1,21 @@
# Generated by Django 5.2.12 on 2026-10-27 17:21
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('compliance', '0002_seed_default_spec'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='productspecification',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='specs_created', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -16,6 +16,10 @@ class ProductSpecification(models.Model):
effective_from = models.DateField(null=True, blank=True)
effective_to = models.DateField(null=True, blank=True)
is_current = models.BooleanField(default=False)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
related_name="specs_created",
)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
related_name="specs_approved",

View file

@ -23,7 +23,10 @@ class ProductSpecificationSerializer(serializers.ModelSerializer):
class Meta:
model = ProductSpecification
fields = "__all__"
read_only_fields = ["id", "approved_by", "approved_at", "created_at", "updated_at"]
read_only_fields = [
"id", "created_by", "status", "is_current", "approved_by",
"approved_at", "created_at", "updated_at",
]
class FacilitySerializer(serializers.ModelSerializer):

View file

@ -370,6 +370,60 @@ def reject_case(case, user, reason):
return case
def specification_approval_error(spec, user):
"""Return why ``user`` may not approve ``spec``, or None when they may.
A specification's parameters are the limits every QC result is judged
against, so approving one is an administrator action and the person who
created it may not also approve it (four-eyes).
"""
from apps.compliance.models import ProductSpecification
if not user.is_authenticated or not (user.is_superuser or user.role == "admin"):
return "Only an administrator may approve a specification."
if spec.status == ProductSpecification.Status.SUPERSEDED:
return "A superseded specification cannot be approved."
if spec.status != ProductSpecification.Status.DRAFT:
return "Only a draft specification can be approved."
if not spec.parameters.all():
return "Add at least one parameter before the specification is approved."
if spec.created_by_id and spec.created_by_id == user.id:
return "You created this specification, so another administrator must approve it."
return None
def approve_specification(spec, user):
"""Approve a draft specification and make it the current one.
Approval supersedes the product's previously current approved version in the
same transaction, so "current" never points at two specifications.
"""
from apps.compliance.models import ProductSpecification
error = specification_approval_error(spec, user)
if error:
raise ValueError(error)
with transaction.atomic():
ProductSpecification.objects.filter(
product=spec.product,
status=ProductSpecification.Status.APPROVED,
is_current=True,
).exclude(pk=spec.pk).update(
status=ProductSpecification.Status.SUPERSEDED,
is_current=False,
updated_at=timezone.now(),
)
spec.status = ProductSpecification.Status.APPROVED
spec.is_current = True
spec.approved_by = user
spec.approved_at = timezone.now()
spec.save(update_fields=[
"status", "is_current", "approved_by", "approved_at", "updated_at",
])
return spec
def record_submission(case, user, dds_reference, submission_date):
from apps.compliance.models import DueDiligenceStatementReference

View file

@ -299,6 +299,87 @@ class LineageAllocationAPITests(TestCase):
self.assertEqual(IntakeBatchLineage.objects.count(), 2)
class SpecificationApprovalAPITests(TestCase):
def setUp(self):
self.admin = make_user("api.admin@ixg.local", "admin")
self.other_admin = make_user("api.admin2@ixg.local", "admin")
self.ops = make_user("api.ops@ixg.local", "ops")
self.client = APIClient()
self.current = ProductSpecification.objects.create(
product="Unrefined Shea Butter", version="8.0",
status=ProductSpecification.Status.APPROVED, is_current=True,
)
SpecParameter.objects.create(
spec=self.current, parameter="moisture", unit="%",
max_value=Decimal("0.100"), sort_order=1,
)
def draft(self, created_by=None, version="2.0"):
spec = ProductSpecification.objects.create(
product="Unrefined Shea Butter", version=version, created_by=created_by,
)
SpecParameter.objects.create(
spec=spec, parameter="moisture", unit="%",
max_value=Decimal("0.100"), sort_order=1,
)
return spec
def test_api_create_forces_a_draft_and_records_the_creator(self):
self.client.force_authenticate(user=self.admin)
resp = self.client.post(
"/api/product-specs/",
{"product": "Unrefined Shea Butter", "version": "2.1",
"status": "approved", "is_current": True},
format="json",
)
self.assertEqual(resp.status_code, 201)
spec = ProductSpecification.objects.get(version="2.1")
self.assertEqual(spec.status, ProductSpecification.Status.DRAFT)
self.assertFalse(spec.is_current)
self.assertEqual(spec.created_by, self.admin)
def test_api_field_edits_cannot_approve_a_specification(self):
spec = self.draft(created_by=self.ops)
self.client.force_authenticate(user=self.admin)
resp = self.client.patch(
f"/api/product-specs/{spec.pk}/",
{"status": "approved", "is_current": True},
format="json",
)
self.assertEqual(resp.status_code, 200)
spec.refresh_from_db()
self.assertEqual(spec.status, ProductSpecification.Status.DRAFT)
self.assertFalse(spec.is_current)
def test_api_approval_is_four_eyes(self):
spec = self.draft(created_by=self.admin)
self.client.force_authenticate(user=self.admin)
resp = self.client.post(f"/api/product-specs/{spec.pk}/approve/")
self.assertEqual(resp.status_code, 400)
self.assertIn("another administrator", resp.data["detail"])
spec.refresh_from_db()
self.assertEqual(spec.status, ProductSpecification.Status.DRAFT)
def test_another_admin_approves_via_the_api(self):
spec = self.draft(created_by=self.admin)
self.client.force_authenticate(user=self.other_admin)
resp = self.client.post(f"/api/product-specs/{spec.pk}/approve/")
self.assertEqual(resp.status_code, 200)
spec.refresh_from_db()
self.current.refresh_from_db()
self.assertEqual(spec.status, ProductSpecification.Status.APPROVED)
self.assertTrue(spec.is_current)
self.assertEqual(spec.approved_by, self.other_admin)
self.assertEqual(self.current.status, ProductSpecification.Status.SUPERSEDED)
self.assertFalse(self.current.is_current)
def test_ops_cannot_use_the_api_approval(self):
spec = self.draft(created_by=self.admin)
self.client.force_authenticate(user=self.ops)
resp = self.client.post(f"/api/product-specs/{spec.pk}/approve/")
self.assertEqual(resp.status_code, 403)
class BuyerIsolationTests(TestCase):
def setUp(self):
self.buyer = make_user("iso.buyer@ixg.local", "buyer")

View file

@ -22,8 +22,24 @@ class ProductSpecificationViewSet(viewsets.ModelViewSet):
serializer_class = ProductSpecificationSerializer
permission_classes = [IsOpsOrAdmin]
def perform_create(self, serializer):
serializer.save(
created_by=self.request.user,
status=ProductSpecification.Status.DRAFT,
is_current=False,
)
@action(detail=True, methods=["post"], permission_classes=[IsAdmin])
def approve(self, request, pk=None):
spec = self.get_object()
try:
services.approve_specification(spec, request.user)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(ProductSpecificationSerializer(spec).data)
def get_permissions(self):
if self.action in ("create", "update", "partial_update", "destroy"):
if self.action in ("create", "update", "partial_update", "destroy", "approve"):
return [permission() for permission in [IsAdmin]]
return [permission() for permission in [IsOpsOrAdmin]]

View file

@ -3,7 +3,7 @@ from django.db import connections
from django.utils import timezone
from django.utils.dateparse import parse_datetime
from apps.guides.models import GuidePage
from apps.guides.models import GUIDE_PATH_PREFIX, GuidePage
SELECT_PAGES_SQL = (
'SELECT path, title, content, "updatedAt" FROM pages '
@ -12,7 +12,7 @@ SELECT_PAGES_SQL = (
class Command(BaseCommand):
help = "One-way sync of Wiki.js user-guides pages into GuidePage (read-only on wikijs)."
help = "One-way sync of Wiki.js guide pages into GuidePage (read-only on wikijs)."
def handle(self, *args, **options):
created = 0
@ -23,7 +23,7 @@ class Command(BaseCommand):
known_paths = set()
with connections["wikijs"].cursor() as cursor:
cursor.execute(SELECT_PAGES_SQL, ["user-guides/%"])
cursor.execute(SELECT_PAGES_SQL, [f"{GUIDE_PATH_PREFIX}%"])
rows = cursor.fetchall()
for path, title, content, updated_at_raw in rows:

View file

@ -6,6 +6,8 @@ import bleach
from django.conf import settings
from markdown import markdown
from .models import GUIDE_PATH_PREFIX
ALLOWED_TAGS = [
"p",
"br",
@ -47,9 +49,9 @@ EXTENSIONS = ["tables", "fenced_code", "sane_lists"]
WIKI_IMAGE_PREFIXES = ("/buyer-guide/", "/ops-guide/")
# Links authored in the wiki are absolute: `/en/user-guides/<x>` is a guide that
# this help centre mirrors, while any other `/en/<x>` is a wiki-only page.
WIKI_GUIDE_PREFIX = "/en/user-guides/"
# Links authored in the wiki are absolute: `/en/home/user-guides/<x>` is a guide
# that this help centre mirrors, while any other `/en/<x>` is a wiki-only page.
WIKI_GUIDE_PREFIX = f"/en/{GUIDE_PATH_PREFIX}"
WIKI_PAGE_PREFIX = "/en/"
_IMAGE_SRC_RE = re.compile(r'(<img\b[^>]*?\bsrc=")([^"]*)(")', re.IGNORECASE)
@ -63,7 +65,7 @@ def _rewrite_absolute_anchor(prefix, href, suffix, help_root):
portal (/portal/, /ops/, /admin/) and has no `/en/` route, so an untouched
`/en/...` link is a 404 for whoever clicks it inside the help centre.
- `/en/user-guides/<x>` is mirrored in-app -> the in-app help path.
- `/en/home/user-guides/<x>` is mirrored in-app -> the in-app help path.
- any other `/en/<x>` exists only in the wiki -> the wiki public base.
"""
path = href.split("#", 1)[0].split("?", 1)[0]
@ -86,8 +88,8 @@ def _rewrite_anchor(match, base_folder, help_root):
rewritten = _rewrite_absolute_anchor(prefix, href, suffix, help_root)
return rewritten if rewritten is not None else match.group(0)
resolved = posixpath.normpath(posixpath.join(base_folder, href))
if resolved.startswith("user-guides/"):
suffix_path = resolved[len("user-guides/"):]
if resolved.startswith(GUIDE_PATH_PREFIX):
suffix_path = resolved[len(GUIDE_PATH_PREFIX):]
return f'{prefix}{help_root}{suffix_path}/{suffix}'
return match.group(0)

View file

@ -1,5 +1,9 @@
from django.db import models
# One structural prefix for every wiki guide path. Wiki.js now keeps all pages
# under a single `home/` root; change this line if that ever moves again.
GUIDE_PATH_PREFIX = "home/user-guides/"
class GuidePage(models.Model):
class Audience(models.TextChoices):
@ -9,10 +13,10 @@ class GuidePage(models.Model):
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),
(f"{GUIDE_PATH_PREFIX}index", Audience.SHARED),
(f"{GUIDE_PATH_PREFIX}buyer/", Audience.BUYER),
(f"{GUIDE_PATH_PREFIX}ops/", Audience.OPS),
(f"{GUIDE_PATH_PREFIX}admin/", Audience.ADMIN),
)
path = models.CharField(max_length=255, unique=True)
@ -30,7 +34,9 @@ class GuidePage(models.Model):
@property
def page_slug(self):
return self.path[len("user-guides/"):] if self.path.startswith("user-guides/") else self.path
if self.path.startswith(GUIDE_PATH_PREFIX):
return self.path[len(GUIDE_PATH_PREFIX):]
return self.path
def preview(self, max_chars=180):
for line in self.content.splitlines():

View file

@ -30,11 +30,11 @@ def make_page(path, content="# Heading\n\nBody text", title=None):
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.shared = make_page("home/user-guides/index", "# User Guides\n\nWelcome.", title="Landing Page")
self.buyer = make_page("home/user-guides/buyer/getting-started", title="Getting Started Guide")
self.buyer2 = make_page("home/user-guides/buyer/dashboard", title="Dashboard Guide")
self.ops = make_page("home/user-guides/ops/batches", title="Ops Batches Guide")
self.admin = make_page("home/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")
@ -168,7 +168,7 @@ class GuideMarkdownTests(TestCase):
def test_script_tags_are_stripped(self):
html = render_markdown(
"# Title\n\nHello <script>alert('x')</script> world",
"user-guides/buyer/test",
"home/user-guides/buyer/test",
"/portal/help/",
)
self.assertNotIn("<script", html)
@ -176,7 +176,7 @@ class GuideMarkdownTests(TestCase):
def test_tables_and_heading_render(self):
html = render_markdown(
"| A | B |\n|---|---|\n| 1 | 2 |",
"user-guides/buyer/test",
"home/user-guides/buyer/test",
"/portal/help/",
)
self.assertIn("<table", html)
@ -186,7 +186,7 @@ class GuideMarkdownTests(TestCase):
def test_same_folder_link_rewritten_to_help_root(self):
html = render_markdown(
"[Compliance guide](compliance)",
"user-guides/buyer/getting-started",
"home/user-guides/buyer/getting-started",
"/portal/help/",
)
self.assertIn('href="/portal/help/buyer/compliance/"', html)
@ -195,7 +195,7 @@ class GuideMarkdownTests(TestCase):
def test_index_relative_link_rewritten(self):
html = render_markdown(
"[Getting started](buyer/getting-started)",
"user-guides/index",
"home/user-guides/index",
"/ops/help/",
)
self.assertIn('href="/ops/help/buyer/getting-started/"', html)
@ -204,7 +204,7 @@ class GuideMarkdownTests(TestCase):
def test_wiki_image_path_rewritten_to_public_base(self):
html = render_markdown(
"![Dashboard](/buyer-guide/buyer-dashboard.png)",
"user-guides/buyer/test",
"home/user-guides/buyer/test",
"/portal/help/",
)
self.assertIn('src="http://wiki.test/buyer-guide/buyer-dashboard.png"', html)
@ -213,7 +213,7 @@ class GuideMarkdownTests(TestCase):
html = render_markdown(
"[Top](#buyer-portal-guides)\n\n[Email](mailto:a@b.c)\n\n"
"[Elsewhere](https://example.com/x)",
"user-guides/index",
"home/user-guides/index",
"/portal/help/",
)
self.assertIn('href="#buyer-portal-guides"', html)
@ -222,16 +222,16 @@ class GuideMarkdownTests(TestCase):
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_absolute_wiki_guide_link_rewritten_to_help_path(self):
"""`/en/user-guides/...` is mirrored in-app, so keep the reader in-app.
"""`/en/home/user-guides/...` is mirrored in-app, so keep the reader in-app.
This is the link form the wiki itself needs (Wiki.js resolves a relative
link against the page's full path, so `walkthrough/signing-in` on the
page `user-guides/ops/walkthrough` 404s). Rewriting it here is what lets
page `home/user-guides/ops/walkthrough` 404s). Rewriting it here is what lets
one page serve both surfaces.
"""
html = render_markdown(
"[Step 3](/en/user-guides/ops/walkthrough/intakes)",
"user-guides/ops/walkthrough",
"[Step 3](/en/home/user-guides/ops/walkthrough/intakes)",
"home/user-guides/ops/walkthrough",
"/ops/help/",
)
self.assertIn('href="/ops/help/ops/walkthrough/intakes/"', html)
@ -239,8 +239,8 @@ class GuideMarkdownTests(TestCase):
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_absolute_wiki_guide_link_to_an_index_page(self):
html = render_markdown(
"[Back to the index](/en/user-guides/index)",
"user-guides/buyer/getting-started",
"[Back to the index](/en/home/user-guides/index)",
"home/user-guides/buyer/getting-started",
"/portal/help/",
)
self.assertIn('href="/portal/help/index/"', html)
@ -253,14 +253,14 @@ class GuideMarkdownTests(TestCase):
"""
html = render_markdown(
"[Development Plan](/en/home/development/plan)",
"user-guides/index",
"home/user-guides/index",
"/ops/help/",
)
self.assertIn('href="http://wiki.test/en/home/development/plan"', html)
def test_absolute_app_paths_left_alone(self):
html = render_markdown(
"[Ops portal](/ops/batches/)", "user-guides/index", "/ops/help/"
"[Ops portal](/ops/batches/)", "home/user-guides/index", "/ops/help/"
)
self.assertIn('href="/ops/batches/"', html)
@ -285,10 +285,10 @@ class FakeCursor:
class SyncGuidesCommandTests(TestCase):
def test_sync_maps_audience_and_upserts(self):
rows = [
("user-guides/index", "User Guides", "# landing", "2026-08-07T13:14:15.809Z"),
("user-guides/buyer/getting-started", "Getting Started", "# gs", "2026-08-07T13:25:30.479Z"),
("user-guides/ops/batches", "Batches", "# b", "2026-08-07T13:51:48.332Z"),
("user-guides/admin/overview", "Admin Overview", "# a", "2026-08-07T13:52:15.763Z"),
("home/user-guides/index", "User Guides", "# landing", "2026-08-07T13:14:15.809Z"),
("home/user-guides/buyer/getting-started", "Getting Started", "# gs", "2026-08-07T13:25:30.479Z"),
("home/user-guides/ops/batches", "Batches", "# b", "2026-08-07T13:51:48.332Z"),
("home/user-guides/admin/overview", "Admin Overview", "# a", "2026-08-07T13:52:15.763Z"),
]
with mock.patch(
"apps.guides.management.commands.sync_guides.connections"
@ -298,23 +298,23 @@ class SyncGuidesCommandTests(TestCase):
self.assertEqual(GuidePage.objects.count(), 4)
self.assertEqual(
GuidePage.objects.get(path="user-guides/index").audience, GuidePage.Audience.SHARED
GuidePage.objects.get(path="home/user-guides/index").audience, GuidePage.Audience.SHARED
)
self.assertEqual(
GuidePage.objects.get(path="user-guides/buyer/getting-started").audience,
GuidePage.objects.get(path="home/user-guides/buyer/getting-started").audience,
GuidePage.Audience.BUYER,
)
self.assertEqual(
GuidePage.objects.get(path="user-guides/ops/batches").audience, GuidePage.Audience.OPS
GuidePage.objects.get(path="home/user-guides/ops/batches").audience, GuidePage.Audience.OPS
)
self.assertEqual(
GuidePage.objects.get(path="user-guides/admin/overview").audience, GuidePage.Audience.ADMIN
GuidePage.objects.get(path="home/user-guides/admin/overview").audience, GuidePage.Audience.ADMIN
)
def test_sync_updates_changed_and_removes_stale(self):
make_page("user-guides/buyer/old", content="old content")
make_page("home/user-guides/buyer/old", content="old content")
rows = [
("user-guides/buyer/getting-started", "Getting Started", "# fresh", "2026-08-07T13:25:30.479Z"),
("home/user-guides/buyer/getting-started", "Getting Started", "# fresh", "2026-08-07T13:25:30.479Z"),
]
with mock.patch(
"apps.guides.management.commands.sync_guides.connections"
@ -322,6 +322,6 @@ class SyncGuidesCommandTests(TestCase):
mock_connections["wikijs"].cursor.return_value = FakeCursor(rows)
call_command("sync_guides")
self.assertFalse(GuidePage.objects.filter(path="user-guides/buyer/old").exists())
page = GuidePage.objects.get(path="user-guides/buyer/getting-started")
self.assertFalse(GuidePage.objects.filter(path="home/user-guides/buyer/old").exists())
page = GuidePage.objects.get(path="home/user-guides/buyer/getting-started")
self.assertEqual(page.content, "# fresh")

View file

@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
from django.views.generic import DetailView, ListView
from .markdown import render_markdown
from .models import GuidePage, allowed_audiences
from .models import GUIDE_PATH_PREFIX, GuidePage, allowed_audiences
class GuideAccessMixin:
@ -51,7 +51,7 @@ class GuideDetailView(LoginRequiredMixin, GuideAccessMixin, DetailView):
context_object_name = "guide"
def get_object(self, queryset=None):
path = f"user-guides/{self.kwargs['page_path']}"
path = f"{GUIDE_PATH_PREFIX}{self.kwargs['page_path']}"
page = get_object_or_404(GuidePage, path=path)
if page.audience not in self.get_allowed_audiences():
raise Http404("This guide is not available in this portal.")

View file

@ -708,14 +708,23 @@ class FacilityForm(forms.ModelForm):
}
class OptionalGeoJSONField(forms.JSONField):
"""Draw a missing polygon as an empty box, not the literal `null`."""
def prepare_value(self, value):
if value in (None, "", {}):
return ""
return super().prepare_value(value)
class OriginZoneForm(forms.ModelForm):
geometry = forms.JSONField(
geometry = OptionalGeoJSONField(
required=False,
label="Geometry (GeoJSON)",
help_text=(
"The zone's polygon as GeoJSON, for example "
'{"type": "Polygon", "coordinates": [[[7.60, 12.99], ...]]}. '
"Stored as given: the map below draws it, it does not check the shape."
"Stored as given: the map beside this form draws it, it does not check the shape."
),
error_messages={"invalid": "Enter valid GeoJSON, or leave this field blank."},
widget=forms.Textarea(

View file

@ -913,6 +913,7 @@ class RegisterAccessTests(ComplianceRegisterBase):
"/ops/facilities/", "/ops/facilities/new/",
f"/ops/facilities/{self.facility.pk}/edit/",
"/ops/origin-zones/", "/ops/origin-zones/new/",
f"/ops/origin-zones/{self.zone.pk}/",
f"/ops/origin-zones/{self.zone.pk}/edit/",
"/ops/collector-groups/", "/ops/collector-groups/new/",
f"/ops/collector-groups/{self.group.pk}/edit/",
@ -1075,6 +1076,38 @@ class OriginZoneRegisterTests(ComplianceRegisterBase):
self.assertIn("No polygon coordinates are stored", html)
self.assertNotIn('id="zone-map"', html)
def test_the_new_form_geometry_is_empty_not_null(self):
r = self.client_ops.get("/ops/origin-zones/new/")
self.assertEqual(r.context["form"]["geometry"].value(), "")
def test_a_zone_has_a_read_only_detail_view_with_the_map(self):
self.zone.geometry = {
"type": "Polygon",
"coordinates": [[[7.6012, 12.9905], [7.618, 12.9905],
[7.618, 13.0042], [7.6012, 13.0042],
[7.6012, 12.9905]]],
}
self.zone.save()
r = self.client_ops.get(f"/ops/origin-zones/{self.zone.pk}/")
self.assertEqual(r.status_code, 200)
html = r.content.decode()
self.assertIn('id="zone-map"', html)
self.assertIn("7.6012", html)
self.assertIn("vendor/leaflet/leaflet.js", html)
self.assertIn("js/ixg_zone_map.js", html)
self.assertIn("a view, not a check", html.lower())
self.assertIn(f"/ops/origin-zones/{self.zone.pk}/edit/", html)
def test_the_detail_view_says_when_there_is_no_geometry(self):
r = self.client_ops.get(f"/ops/origin-zones/{self.zone.pk}/")
html = r.content.decode()
self.assertIn("No polygon coordinates are stored", html)
self.assertNotIn('id="zone-map"', html)
def test_the_list_links_each_zone_to_its_detail_view(self):
html = self.client_ops.get("/ops/origin-zones/").content.decode()
self.assertIn(f'href="/ops/origin-zones/{self.zone.pk}/"', html)
class CollectorGroupRegisterTests(ComplianceRegisterBase):
def payload(self, **overrides):
@ -1135,6 +1168,25 @@ class EUDRCaseChildBase(ComplianceRegisterBase):
return DueDiligenceCase.objects.create(batch=batch)
class DueDiligenceNamingTests(EUDRCaseChildBase):
def test_the_sidebar_and_case_screen_name_due_diligence(self):
list_html = self.client_ops.get("/ops/eudr/").content.decode()
self.assertIn("Due Diligence (EUDR)", list_html)
detail = self.client_ops.get(self.case_url()).content.decode()
self.assertIn("Due Diligence Case:", detail)
self.assertIn("due diligence case", detail)
for heading in (
"Due diligence completeness check",
"Intake lineage (origin)",
"Evidence",
"Risk assessments",
"Mitigation actions",
"Statement references (DDS)",
):
with self.subTest(heading=heading):
self.assertIn(heading, detail)
class EUDREvidenceTests(EUDRCaseChildBase):
def payload(self, **overrides):
data = {
@ -1373,6 +1425,7 @@ class SpecificationCreateTests(SpecificationBase):
spec = ProductSpecification.objects.get(version="2.0")
self.assertEqual(spec.status, ProductSpecification.Status.DRAFT)
self.assertFalse(spec.is_current)
self.assertEqual(spec.created_by, self.ops)
self.assertEqual(spec.parameters.count(), 1)
self.assertEqual(spec.parameters.first().parameter, "moisture")
@ -1474,3 +1527,91 @@ class SpecificationEditTests(SpecificationBase):
def test_a_buyer_cannot_post_a_specification(self):
r = self.client_buyer.post("/ops/specifications/new/", self.create_payload())
self.assertEqual(r.status_code, 403)
class SpecificationApprovalTests(SpecificationBase):
"""Approval is an administrator action and never a single-person act."""
def setUp(self):
super().setUp()
self.admin = User.objects.create_user(
email="spec-admin@ixg.local", password="pass1234", role="admin",
full_name="Spec Admin", is_staff=True,
)
self.other_admin = User.objects.create_user(
email="spec-admin2@ixg.local", password="pass1234", role="admin",
full_name="Other Spec Admin", is_staff=True,
)
self.client_admin = Client(SERVER_NAME="ops.ixgltd.com")
self.client_admin.force_login(self.admin)
self.client_other_admin = Client(SERVER_NAME="ops.ixgltd.com")
self.client_other_admin.force_login(self.other_admin)
def approve(self, client, spec):
return client.post(f"/ops/specifications/{spec.pk}/approve/", follow=True)
def test_an_ops_officer_cannot_approve_a_specification(self):
self.client_ops.post("/ops/specifications/new/", self.create_payload())
spec = ProductSpecification.objects.get(version="2.0")
r = self.client_ops.post(f"/ops/specifications/{spec.pk}/approve/")
self.assertEqual(r.status_code, 403)
spec.refresh_from_db()
self.assertEqual(spec.status, ProductSpecification.Status.DRAFT)
self.assertFalse(spec.is_current)
def test_an_admin_approves_an_ops_draft_and_supersedes_the_old_version(self):
self.client_ops.post("/ops/specifications/new/", self.create_payload())
spec = ProductSpecification.objects.get(version="2.0")
r = self.approve(self.client_admin, spec)
self.assertEqual(r.status_code, 200)
spec.refresh_from_db()
self.spec.refresh_from_db()
self.assertEqual(spec.status, ProductSpecification.Status.APPROVED)
self.assertTrue(spec.is_current)
self.assertEqual(spec.approved_by, self.admin)
self.assertIsNotNone(spec.approved_at)
self.assertEqual(self.spec.status, ProductSpecification.Status.SUPERSEDED)
self.assertFalse(self.spec.is_current)
self.assertContains(r, "approved and made current")
def test_an_admin_cannot_approve_a_specification_they_created(self):
self.client_admin.post("/ops/specifications/new/", self.create_payload())
spec = ProductSpecification.objects.get(version="2.0")
self.assertEqual(spec.created_by, self.admin)
r = self.approve(self.client_admin, spec)
self.assertContains(r, "another administrator must approve it")
spec.refresh_from_db()
self.assertEqual(spec.status, ProductSpecification.Status.DRAFT)
self.assertFalse(spec.is_current)
self.assertIsNone(spec.approved_by)
def test_a_different_admin_can_approve_it(self):
self.client_admin.post("/ops/specifications/new/", self.create_payload())
spec = ProductSpecification.objects.get(version="2.0")
self.approve(self.client_other_admin, spec)
spec.refresh_from_db()
self.assertEqual(spec.status, ProductSpecification.Status.APPROVED)
self.assertEqual(spec.approved_by, self.other_admin)
def test_a_specification_without_parameters_cannot_be_approved(self):
spec = ProductSpecification.objects.create(
product="Unrefined Shea Butter", version="9.0"
)
r = self.approve(self.client_admin, spec)
self.assertContains(r, "at least one parameter")
spec.refresh_from_db()
self.assertEqual(spec.status, ProductSpecification.Status.DRAFT)
def test_an_already_approved_specification_cannot_be_approved_again(self):
r = self.approve(self.client_admin, self.spec)
self.assertContains(r, "Only a draft specification")
self.spec.refresh_from_db()
self.assertEqual(self.spec.status, ProductSpecification.Status.APPROVED)
def test_the_approve_control_only_appears_for_admins_who_may_use_it(self):
self.client_admin.post("/ops/specifications/new/", self.create_payload())
spec = ProductSpecification.objects.get(version="2.0")
approve_url = f"/ops/specifications/{spec.pk}/approve/"
self.assertNotContains(self.client_ops.get("/ops/specifications/"), approve_url)
self.assertNotContains(self.client_admin.get("/ops/specifications/"), approve_url)
self.assertContains(self.client_other_admin.get("/ops/specifications/"), approve_url)

View file

@ -18,6 +18,7 @@ urlpatterns = [
path('facilities/<uuid:pk>/edit/', views.FacilityUpdateView.as_view(), name='facility_update'),
path('origin-zones/', views.OriginZoneListView.as_view(), name='origin_zone_list'),
path('origin-zones/new/', views.OriginZoneCreateView.as_view(), name='origin_zone_create'),
path('origin-zones/<uuid:pk>/', views.OriginZoneDetailView.as_view(), name='origin_zone_detail'),
path('origin-zones/<uuid:pk>/edit/', views.OriginZoneUpdateView.as_view(), name='origin_zone_update'),
path('collector-groups/', views.CollectorGroupListView.as_view(), name='collector_group_list'),
path('collector-groups/new/', views.CollectorGroupCreateView.as_view(), name='collector_group_create'),
@ -25,6 +26,7 @@ urlpatterns = [
path('specifications/', views.SpecificationListView.as_view(), name='specification_list'),
path('specifications/new/', views.SpecificationCreateView.as_view(), name='specification_create'),
path('specifications/<uuid:pk>/edit/', views.SpecificationUpdateView.as_view(), name='specification_update'),
path('specifications/<uuid:pk>/approve/', views.SpecificationApproveView.as_view(), name='specification_approve'),
path('qc/', views.QCListView.as_view(), name='qc_list'),
path('qc/<uuid:pk>/', views.QCDetailView.as_view(), name='qc_detail'),
path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'),

View file

@ -92,6 +92,12 @@ class OpsRequiredMixin(UserPassesTestMixin):
return user.is_authenticated and (user.role in ('ops', 'admin') or user.is_superuser)
class AdminRequiredMixin(UserPassesTestMixin):
def test_func(self):
user = self.request.user
return user.is_authenticated and (user.role == 'admin' or user.is_superuser)
class DashboardView(OpsRequiredMixin, TemplateView):
template_name = 'ops/dashboard.html'
@ -917,6 +923,16 @@ class OriginZoneListView(OpsRequiredMixin, ListView):
return ctx
def add_zone_map_context(ctx, zone):
"""Share the zone-map context between the read-only view and the form."""
ctx['show_zone_map'] = True
ctx['tile_url'] = browser_tile_url()
geometry = getattr(zone, 'geometry', None) if zone is not None else None
ctx['zone_has_geometry'] = bool(geometry and geometry.get('coordinates'))
ctx['zone_geometry_json'] = json.dumps(geometry) if ctx['zone_has_geometry'] else ''
return ctx
class OriginZoneFormContextMixin:
model = OriginZone
form_class = OriginZoneForm
@ -925,13 +941,7 @@ class OriginZoneFormContextMixin:
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['cancel_url'] = reverse('ops:origin_zone_list')
ctx['show_zone_map'] = True
ctx['tile_url'] = browser_tile_url()
zone = getattr(self, 'object', None)
geometry = getattr(zone, 'geometry', None) if zone is not None else None
ctx['zone_has_geometry'] = bool(geometry and geometry.get('coordinates'))
ctx['zone_geometry_json'] = json.dumps(geometry) if ctx['zone_has_geometry'] else ''
return ctx
return add_zone_map_context(ctx, getattr(self, 'object', None))
def form_valid(self, form):
apply_zone_verification(
@ -942,6 +952,26 @@ class OriginZoneFormContextMixin:
return super().form_valid(form)
class OriginZoneDetailView(OpsRequiredMixin, DetailView):
"""Read-only zone view whose main feature is the stored polygon.
The list used to send every row straight to the edit form, so "looking at a
zone" meant opening an editor. This screen is the place to look; editing is
one explicit button away.
"""
model = OriginZone
template_name = 'ops/origin_zone_detail.html'
context_object_name = 'zone'
def get_queryset(self):
return OriginZone.objects.select_related('facility', 'verified_by')
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
return add_zone_map_context(ctx, self.object)
class OriginZoneCreateView(OriginZoneFormContextMixin, OpsRequiredMixin, CreateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
@ -954,7 +984,7 @@ class OriginZoneCreateView(OriginZoneFormContextMixin, OpsRequiredMixin, CreateV
messages.success(
self.request,
f"Origin zone {self.object.zone_code} created. "
"The map below is a view of the stored polygon, not a check of it.",
"The map beside the form is a view of the stored polygon, not a check of it.",
)
return response
@ -1183,9 +1213,9 @@ class StatementReferenceUpdateView(CaseChildMixin, UpdateView):
#
# A specification is the set of limits every QC result is judged against. The
# portal can create and edit a specification and its parameters together, but
# the **approval half is held**: status, is_current, approved_by and approved_at
# are not on the form, so a portal-created specification is a draft and cannot
# be approved here. See apps/ops/forms.py for the reasoning.
# status, is_current, approved_by and approved_at are not on the form: approval
# is a separate administrator action in apps.compliance.services, and the
# creator may not approve their own specification (four-eyes).
class SpecificationListView(OpsRequiredMixin, ListView):
@ -1211,6 +1241,10 @@ class SpecificationListView(OpsRequiredMixin, ListView):
ctx['status_choices'] = ProductSpecification.Status.choices
ctx['current_product'] = self.request.GET.get('product', '')
ctx['current_status'] = self.request.GET.get('status', '')
user = self.request.user
ctx['show_approval'] = bool(user.is_superuser or user.role == 'admin')
for spec in ctx['specs']:
spec.approval_error = services.specification_approval_error(spec, user)
return ctx
@ -1234,13 +1268,27 @@ class SpecificationFormContextMixin:
and spec.pk
and spec.status != ProductSpecification.Status.DRAFT
)
ctx['show_approval'] = bool(
spec is not None
and spec.pk
and (
self.request.user.is_superuser
or self.request.user.role == 'admin'
)
)
ctx['approval_error'] = (
services.specification_approval_error(spec, self.request.user)
if ctx['show_approval']
else None
)
return ctx
def forms_valid(self, form, formset):
self.object = form.save(commit=False)
if self.object._state.adding:
# The approval fields are not on the form; a portal-created spec is
# always a draft, never current.
# always a draft, never current, and keeps its creator for four-eyes.
self.object.created_by = self.request.user
self.object.status = ProductSpecification.Status.DRAFT
self.object.is_current = False
self.object.save()
@ -1274,6 +1322,24 @@ class SpecificationCreateView(SpecificationFormContextMixin, OpsRequiredMixin, C
return self.forms_invalid(form, formset)
class SpecificationApproveView(AdminRequiredMixin, View):
http_method_names = ['post']
def post(self, request, pk):
spec = get_object_or_404(ProductSpecification, pk=pk)
try:
services.approve_specification(spec, request.user)
except ValueError as exc:
messages.error(request, str(exc))
return redirect('ops:specification_update', pk=spec.pk)
messages.success(
request,
f"Specification {spec.product} v{spec.version} approved and made "
"current. The previously current version is now superseded.",
)
return redirect('ops:specification_list')
class SpecificationUpdateView(SpecificationFormContextMixin, OpsRequiredMixin, UpdateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)

View file

@ -46,7 +46,7 @@ body { font-size: .875rem; background: #f5f6fa; }
<li class="nav-item"><a class="nav-link {% if 'qc' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:qc_list' %}"><i class="bi bi-clipboard-check"></i> QC Results</a></li>
<li class="nav-item"><a class="nav-link {% if 'verify' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:qc_verify_list' %}"><i class="bi bi-person-check"></i> QC Approvals</a></li>
<li class="nav-item"><a class="nav-link {% if 'coa' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:coa_review_list' %}"><i class="bi bi-file-earmark-medical"></i> CoA Reviews</a></li>
<li class="nav-item"><a class="nav-link {% if 'eudr' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:eudr_list' %}"><i class="bi bi-shield-check"></i> EUDR Compliance</a></li>
<li class="nav-item"><a class="nav-link {% if 'eudr' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:eudr_list' %}"><i class="bi bi-shield-check"></i> Due Diligence (EUDR)</a></li>
<li class="nav-item"><a class="nav-link {% if 'shipment' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:shipment_list' %}"><i class="bi bi-truck"></i> Shipments</a></li>
<li class="nav-item"><a class="nav-link {% if 'capa' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:capa_list' %}"><i class="bi bi-exclamation-triangle"></i> CAPA Records</a></li>
<li class="nav-item"><a class="nav-link {% if 'document' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:document_list' %}"><i class="bi bi-file-earmark-text"></i> Documents</a></li>

View file

@ -1,9 +1,9 @@
{% extends 'ops/base_ops.html' %}
{% block title %}EUDR Case - IXG Operations{% endblock %}
{% block title %}Due Diligence Case - IXG Operations{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h2>EUDR Case: {{ case.batch.batch_reference }}</h2>
<h2>Due Diligence Case: {{ case.batch.batch_reference }}</h2>
<a href="{% url 'ops:eudr_list' %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
</div>
@ -11,10 +11,15 @@
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
{% endif %}
<p class="text-secondary small">
This is the <strong>due diligence case</strong> for batch {{ case.batch.batch_reference }}:
the evidence, risk assessment and mitigation record behind its EUDR statement.
</p>
<div class="row g-3">
<div class="col-md-8">
<div class="stat-card mb-3">
<h6 class="mb-3">Completeness Check</h6>
<h6 class="mb-3">Due diligence completeness check</h6>
{% if case.completeness_results %}
<table class="table table-sm mb-0">
<thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>
@ -34,7 +39,7 @@
</div>
<div class="stat-card mb-3">
<h6 class="mb-3">Intake Lineage</h6>
<h6 class="mb-3">Intake lineage (origin)</h6>
{% if lineage %}
<table class="table table-sm mb-0">
<thead><tr><th>Intake</th><th>Date</th><th>Weight (kg)</th><th>Zone</th><th>Zone Status</th><th>Collector</th></tr></thead>
@ -60,7 +65,7 @@
<div class="stat-card mb-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">EUDR Evidence</h6>
<h6 class="mb-0">Evidence</h6>
<a href="{% url 'ops:eudr_evidence_create' case.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-paperclip"></i> Attach evidence</a>
</div>
{% for e in evidence %}
@ -75,7 +80,7 @@
</div>
<div class="stat-card mb-3">
<h6 class="mb-3">Risk Assessments</h6>
<h6 class="mb-3">Risk assessments</h6>
{% for r in risk_assessments %}
<div class="border-bottom pb-2 mb-2">
<span class="badge {% if r.risk_level == 'high' %}bg-danger{% elif r.risk_level == 'low' %}bg-success{% else %}bg-info text-dark{% endif %}">{{ r.get_risk_level_display }}</span>
@ -89,7 +94,7 @@
<div class="stat-card mb-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Mitigation Actions</h6>
<h6 class="mb-0">Mitigation actions</h6>
<a href="{% url 'ops:eudr_mitigation_create' case.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-plus-lg"></i> Add action</a>
</div>
{% for m in mitigation_actions %}
@ -104,7 +109,7 @@
</div>
<div class="stat-card">
<h6 class="mb-3">External Submission (DDS) References</h6>
<h6 class="mb-3">Statement references (DDS)</h6>
{% for s in statement_references %}
<div class="border-bottom pb-2 mb-2">
<span class="badge bg-secondary">{{ s.reconciliation_status }}</span>

View file

@ -1,15 +1,15 @@
{% extends 'ops/base_ops.html' %}
{% block title %}EUDR Compliance - IXG Operations{% endblock %}
{% block title %}Due Diligence (EUDR) - IXG Operations{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h2>EUDR Due Diligence Cases</h2>
<h2>Due Diligence (EUDR)</h2>
<span class="text-secondary">{{ page_obj.paginator.count }} total</span>
</div>
<div class="alert alert-light border py-2 small">
<i class="bi bi-info-circle"></i> Shea butter is not listed in EUDR Annex I; this tracker provides due-diligence
evidence and buyer assurance, not an EU-mandated filing.
<i class="bi bi-info-circle"></i> Each row is a <strong>due diligence case</strong> for a batch. Shea butter is not
listed in EUDR Annex I; this tracker provides due-diligence evidence and buyer assurance, not an EU-mandated filing.
</div>
<div class="filter-bar">

View file

@ -0,0 +1,125 @@
{% extends 'ops/base_ops.html' %}
{% load static %}
{% block title %}{{ zone.zone_code }} - Origin Zone - IXG Operations{% endblock %}
{% block extra_head %}
{% if show_zone_map %}
<link rel="stylesheet" href="{% static 'vendor/leaflet/leaflet.css' %}">
{% endif %}
{% endblock %}
{% block content %}
{% if messages %}
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
{% endif %}
<div class="page-header d-flex justify-content-between align-items-center">
<div>
<h2>Origin zone {{ zone.zone_code }}</h2>
<div class="text-secondary small">
{{ zone.facility.name }}
&middot; {{ zone.country }}{% if zone.region %} &middot; {{ zone.region }}{% endif %}
</div>
</div>
<div>
<a href="{% url 'ops:origin_zone_update' zone.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i> Edit zone</a>
<a href="{% url 'ops:origin_zone_list' %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
</div>
</div>
<div class="row g-3">
<div class="col-lg-7">
{% if show_zone_map %}
<div class="stat-card">
<h6 class="mb-3"><i class="bi bi-geo-alt"></i> Area on a map</h6>
{% if zone_has_geometry %}
<div id="zone-map" data-geometry="{{ zone_geometry_json }}" data-tile-url="{{ tile_url }}"
style="height:420px;border:1px solid #cbd5e1;border-radius:.375rem;background:#eef2f5"></div>
<p class="small text-secondary mt-2 mb-0">
Drawn from the stored polygon. A view, not a check: it does not validate
the geometry and it does not verify the zone.
</p>
{% else %}
<div class="text-center text-secondary py-5" style="border:1px dashed #cbd5e1;border-radius:.375rem;background:#eef2f5">
<i class="bi bi-map" style="font-size:2rem"></i>
<p class="small mt-2 mb-0">
No polygon coordinates are stored for this zone yet, so there is nothing
to draw. Edit the zone, paste GeoJSON into the Geometry field and save,
and the area appears here.
</p>
</div>
<p class="small text-secondary mt-2 mb-0">
A view, not a check: it does not validate the geometry and it does not
verify the zone.
</p>
{% endif %}
</div>
{% endif %}
</div>
<div class="col-lg-5">
<div class="stat-card">
<h6 class="mb-3"><i class="bi bi-clipboard-data"></i> Zone record</h6>
<table class="table table-sm table-borderless mb-0">
<tr>
<td class="text-secondary">Facility</td>
<td>{{ zone.facility.name }}</td>
</tr>
<tr>
<td class="text-secondary">Verification</td>
<td>
{% if zone.verification_status == 'verified' %}
<span class="badge bg-success badge-status">{{ zone.get_verification_status_display }}</span>
{% elif zone.verification_status == 'rejected' %}
<span class="badge bg-danger badge-status">{{ zone.get_verification_status_display }}</span>
{% else %}
<span class="badge bg-warning text-dark badge-status">{{ zone.get_verification_status_display }}</span>
{% endif %}
</td>
</tr>
<tr>
<td class="text-secondary">Verified by</td>
<td>
{% if zone.verified_by %}
{{ zone.verified_by.full_name|default:zone.verified_by.email }}
{% else %}-{% endif %}
</td>
</tr>
<tr>
<td class="text-secondary">Verified on</td>
<td>{{ zone.verified_at|date:'d/m/Y H:i'|default:'-' }}</td>
</tr>
<tr>
<td class="text-secondary">Coordinate source</td>
<td>{{ zone.coordinate_source|default:'-' }}</td>
</tr>
<tr>
<td class="text-secondary">Collected by</td>
<td>{{ zone.coordinates_collected_by|default:'-' }}</td>
</tr>
<tr>
<td class="text-secondary">Collected on</td>
<td>{{ zone.coordinates_collected_at|date:'d/m/Y'|default:'-' }}</td>
</tr>
<tr>
<td class="text-secondary">Supporting file</td>
<td>{{ zone.evidence_file|default:'-' }}</td>
</tr>
<tr>
<td class="text-secondary">Registered</td>
<td>{{ zone.created_at|date:'d/m/Y' }}</td>
</tr>
</table>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{% if show_zone_map and zone_has_geometry %}
<script src="{% static 'vendor/leaflet/leaflet.js' %}"></script>
<script src="{% static 'js/ixg_zone_map.js' %}"></script>
{% endif %}
{% endblock %}

View file

@ -49,7 +49,7 @@
<tbody>
{% for z in zones %}
<tr>
<td><strong>{{ z.zone_code }}</strong></td>
<td><a href="{% url 'ops:origin_zone_detail' z.pk %}"><strong>{{ z.zone_code }}</strong></a></td>
<td>{{ z.facility.name|default:'-' }}</td>
<td>{{ z.country }}</td>
<td>{{ z.region|default:'-' }}</td>

View file

@ -14,8 +14,7 @@
{% if editing_approved %}
<div class="alert alert-warning">
<strong>This specification is not a draft.</strong> Its parameters are the limits QC results
are judged against, so editing them changes those limits. Approval is not available in the
portal yet — it is held until the approval control is decided.
are judged against, so editing them changes those limits.
</div>
{% endif %}
@ -119,13 +118,43 @@
</div>
<div class="stat-card">
<h6 class="mb-3"><i class="bi bi-shield-lock"></i> Approval is held</h6>
<h6 class="mb-3"><i class="bi bi-shield-lock"></i> Approval (four-eyes)</h6>
{% if object and object.pk %}
{% if object.status == 'approved' %}
<p class="small mb-0">
Creating and editing is available here, but the portal does not approve a
specification. A new one is saved as a draft. Approving it — the step that makes
its limits the ones QC is judged against — waits for the approval control to be
decided.
Approved{% if object.approved_by %} by {{ object.approved_by.full_name|default:object.approved_by.email }}{% endif %}{% if object.approved_at %} on {{ object.approved_at|date:'d/m/Y H:i' }}{% endif %}.
{% if object.is_current %}It is the current specification for this product.{% endif %}
</p>
{% elif object.status == 'superseded' %}
<p class="small mb-0">This version is superseded. It can no longer be approved.</p>
{% elif show_approval %}
{% if approval_error %}
<p class="small mb-2 text-danger">{{ approval_error }}</p>
{% else %}
<p class="small mb-2">
Approving makes these parameters the limits QC is judged against, and
supersedes the product's previously current version.
</p>
<form method="post" action="{% url 'ops:specification_approve' object.pk %}"
onsubmit="return confirm('Approve this specification and make it current? This changes the limits QC is judged against.');">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-success w-100"><i class="bi bi-check-circle"></i> Approve and make current</button>
</form>
{% endif %}
{% else %}
<p class="small mb-0">
Approval is an administrator action. Save this specification as a draft; an
administrator can then approve it. The creator may not approve their own
specification.
</p>
{% endif %}
{% else %}
<p class="small mb-0">
A new specification is saved as a draft. Approval is a separate administrator
action, and the creator may not approve their own specification, so a second
person makes the limits live.
</p>
{% endif %}
</div>
</div>
</div>

View file

@ -16,8 +16,9 @@
<div class="alert alert-light border py-2 small">
A specification is the set of limits every QC result is judged against. The portal can
create and edit one, but <strong>approval is held</strong>: a specification created here
is a draft and cannot be approved from the portal yet.
create and edit one; <strong>approval is a separate administrator action</strong>, and
nobody may approve a specification they created (four-eyes). A new specification is
saved as a draft until an administrator approves it.
</div>
<div class="filter-bar">
@ -72,7 +73,16 @@
<td>{{ s.effective_from|date:'d/m/Y'|default:'-' }}</td>
<td>{{ s.effective_to|date:'d/m/Y'|default:'-' }}</td>
<td>{{ s.parameters.all|length }}</td>
<td><a href="{% url 'ops:specification_update' s.pk %}" class="btn btn-sm btn-outline-primary">Edit</a></td>
<td>
<a href="{% url 'ops:specification_update' s.pk %}" class="btn btn-sm btn-outline-primary">Edit</a>
{% if show_approval and s.status == 'draft' and not s.approval_error %}
<form method="post" action="{% url 'ops:specification_approve' s.pk %}" class="d-inline"
onsubmit="return confirm('Approve {{ s.product|escapejs }} v{{ s.version|escapejs }} and make it the current specification? This changes the limits QC is judged against.');">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-success"><i class="bi bi-check-circle"></i> Approve</button>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="8" class="text-muted text-center py-3">No specifications yet</td></tr>