diff --git a/apps/compliance/migrations/0003_productspecification_created_by.py b/apps/compliance/migrations/0003_productspecification_created_by.py new file mode 100644 index 0000000..c7dbcd2 --- /dev/null +++ b/apps/compliance/migrations/0003_productspecification_created_by.py @@ -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), + ), + ] diff --git a/apps/compliance/models.py b/apps/compliance/models.py index b29a657..2ae465f 100644 --- a/apps/compliance/models.py +++ b/apps/compliance/models.py @@ -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", diff --git a/apps/compliance/serializers.py b/apps/compliance/serializers.py index f2728af..7c15df1 100644 --- a/apps/compliance/serializers.py +++ b/apps/compliance/serializers.py @@ -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): diff --git a/apps/compliance/services.py b/apps/compliance/services.py index 0fe6094..59e9c42 100644 --- a/apps/compliance/services.py +++ b/apps/compliance/services.py @@ -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 diff --git a/apps/compliance/tests.py b/apps/compliance/tests.py index 58c375b..e48dfea 100644 --- a/apps/compliance/tests.py +++ b/apps/compliance/tests.py @@ -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") diff --git a/apps/compliance/views.py b/apps/compliance/views.py index 85b73e1..59257f8 100644 --- a/apps/compliance/views.py +++ b/apps/compliance/views.py @@ -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]] diff --git a/apps/guides/management/commands/sync_guides.py b/apps/guides/management/commands/sync_guides.py index fefbca4..8a34ad1 100644 --- a/apps/guides/management/commands/sync_guides.py +++ b/apps/guides/management/commands/sync_guides.py @@ -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: diff --git a/apps/guides/markdown.py b/apps/guides/markdown.py index 499ce47..9e67e8b 100644 --- a/apps/guides/markdown.py +++ b/apps/guides/markdown.py @@ -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/` is a guide that -# this help centre mirrors, while any other `/en/` is a wiki-only page. -WIKI_GUIDE_PREFIX = "/en/user-guides/" +# Links authored in the wiki are absolute: `/en/home/user-guides/` is a guide +# that this help centre mirrors, while any other `/en/` is a wiki-only page. +WIKI_GUIDE_PREFIX = f"/en/{GUIDE_PATH_PREFIX}" WIKI_PAGE_PREFIX = "/en/" _IMAGE_SRC_RE = re.compile(r'(]*?\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/` is mirrored in-app -> the in-app help path. + - `/en/home/user-guides/` is mirrored in-app -> the in-app help path. - any other `/en/` 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) diff --git a/apps/guides/models.py b/apps/guides/models.py index b386d81..6f77ef2 100644 --- a/apps/guides/models.py +++ b/apps/guides/models.py @@ -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(): diff --git a/apps/guides/tests.py b/apps/guides/tests.py index 6fa8057..01777e9 100644 --- a/apps/guides/tests.py +++ b/apps/guides/tests.py @@ -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 world", - "user-guides/buyer/test", + "home/user-guides/buyer/test", "/portal/help/", ) self.assertNotIn("/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//', views.OriginZoneDetailView.as_view(), name='origin_zone_detail'), path('origin-zones//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//edit/', views.SpecificationUpdateView.as_view(), name='specification_update'), + path('specifications//approve/', views.SpecificationApproveView.as_view(), name='specification_approve'), path('qc/', views.QCListView.as_view(), name='qc_list'), path('qc//', views.QCDetailView.as_view(), name='qc_detail'), path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'), diff --git a/apps/ops/views.py b/apps/ops/views.py index 3465ba1..71e907e 100644 --- a/apps/ops/views.py +++ b/apps/ops/views.py @@ -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) diff --git a/templates/ops/base_ops.html b/templates/ops/base_ops.html index 6c7a5b4..88791a0 100644 --- a/templates/ops/base_ops.html +++ b/templates/ops/base_ops.html @@ -46,7 +46,7 @@ body { font-size: .875rem; background: #f5f6fa; } - + diff --git a/templates/ops/eudr_detail.html b/templates/ops/eudr_detail.html index 65f099c..e5fd3ae 100644 --- a/templates/ops/eudr_detail.html +++ b/templates/ops/eudr_detail.html @@ -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 %} @@ -11,10 +11,15 @@ {% for m in messages %}
{{ m }}
{% endfor %} {% endif %} +

+ This is the due diligence case for batch {{ case.batch.batch_reference }}: + the evidence, risk assessment and mitigation record behind its EUDR statement. +

+
-
Completeness Check
+
Due diligence completeness check
{% if case.completeness_results %} @@ -34,7 +39,7 @@
-
Intake Lineage
+
Intake lineage (origin)
{% if lineage %}
CheckStatusDetail
@@ -60,7 +65,7 @@
-
EUDR Evidence
+
Evidence
Attach evidence
{% for e in evidence %} @@ -75,7 +80,7 @@
-
Risk Assessments
+
Risk assessments
{% for r in risk_assessments %}
{{ r.get_risk_level_display }} @@ -89,7 +94,7 @@
-
Mitigation Actions
+
Mitigation actions
Add action
{% for m in mitigation_actions %} @@ -104,7 +109,7 @@
-
External Submission (DDS) References
+
Statement references (DDS)
{% for s in statement_references %}
{{ s.reconciliation_status }} diff --git a/templates/ops/eudr_list.html b/templates/ops/eudr_list.html index f646a21..72d93c1 100644 --- a/templates/ops/eudr_list.html +++ b/templates/ops/eudr_list.html @@ -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 %}
- Shea butter is not listed in EUDR Annex I; this tracker provides due-diligence - evidence and buyer assurance, not an EU-mandated filing. + Each row is a due diligence case 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.
diff --git a/templates/ops/origin_zone_detail.html b/templates/ops/origin_zone_detail.html new file mode 100644 index 0000000..aaf7e05 --- /dev/null +++ b/templates/ops/origin_zone_detail.html @@ -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 %} + +{% endif %} +{% endblock %} + +{% block content %} + +{% if messages %} + {% for m in messages %}
{{ m }}
{% endfor %} +{% endif %} + + + +
+
+ {% if show_zone_map %} +
+
Area on a map
+ {% if zone_has_geometry %} +
+

+ Drawn from the stored polygon. A view, not a check: it does not validate + the geometry and it does not verify the zone. +

+ {% else %} +
+ +

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

+
+

+ A view, not a check: it does not validate the geometry and it does not + verify the zone. +

+ {% endif %} +
+ {% endif %} +
+ +
+
+
Zone record
+
IntakeDateWeight (kg)ZoneZone StatusCollector
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Facility{{ zone.facility.name }}
Verification + {% if zone.verification_status == 'verified' %} + {{ zone.get_verification_status_display }} + {% elif zone.verification_status == 'rejected' %} + {{ zone.get_verification_status_display }} + {% else %} + {{ zone.get_verification_status_display }} + {% endif %} +
Verified by + {% if zone.verified_by %} + {{ zone.verified_by.full_name|default:zone.verified_by.email }} + {% else %}-{% endif %} +
Verified on{{ zone.verified_at|date:'d/m/Y H:i'|default:'-' }}
Coordinate source{{ zone.coordinate_source|default:'-' }}
Collected by{{ zone.coordinates_collected_by|default:'-' }}
Collected on{{ zone.coordinates_collected_at|date:'d/m/Y'|default:'-' }}
Supporting file{{ zone.evidence_file|default:'-' }}
Registered{{ zone.created_at|date:'d/m/Y' }}
+
+
+
+ +{% endblock %} + +{% block extra_js %} +{% if show_zone_map and zone_has_geometry %} + + +{% endif %} +{% endblock %} diff --git a/templates/ops/origin_zone_list.html b/templates/ops/origin_zone_list.html index 00b5d6c..5ac12e9 100644 --- a/templates/ops/origin_zone_list.html +++ b/templates/ops/origin_zone_list.html @@ -49,7 +49,7 @@ {% for z in zones %} - {{ z.zone_code }} + {{ z.zone_code }} {{ z.facility.name|default:'-' }} {{ z.country }} {{ z.region|default:'-' }} diff --git a/templates/ops/specification_form.html b/templates/ops/specification_form.html index 631e3ee..998887d 100644 --- a/templates/ops/specification_form.html +++ b/templates/ops/specification_form.html @@ -14,8 +14,7 @@ {% if editing_approved %}
This specification is not a draft. 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.
{% endif %} @@ -119,13 +118,43 @@
-
Approval is held
-

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

+
Approval (four-eyes)
+ {% if object and object.pk %} + {% if object.status == 'approved' %} +

+ 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 %} +

+ {% elif object.status == 'superseded' %} +

This version is superseded. It can no longer be approved.

+ {% elif show_approval %} + {% if approval_error %} +

{{ approval_error }}

+ {% else %} +

+ Approving makes these parameters the limits QC is judged against, and + supersedes the product's previously current version. +

+
+ {% csrf_token %} + +
+ {% endif %} + {% else %} +

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

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

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

+ {% endif %}
diff --git a/templates/ops/specification_list.html b/templates/ops/specification_list.html index a06c53b..2af7d5c 100644 --- a/templates/ops/specification_list.html +++ b/templates/ops/specification_list.html @@ -16,8 +16,9 @@
A specification is the set of limits every QC result is judged against. The portal can - create and edit one, but approval is held: a specification created here - is a draft and cannot be approved from the portal yet. + create and edit one; approval is a separate administrator action, and + nobody may approve a specification they created (four-eyes). A new specification is + saved as a draft until an administrator approves it.
@@ -72,7 +73,16 @@ {{ s.effective_from|date:'d/m/Y'|default:'-' }} {{ s.effective_to|date:'d/m/Y'|default:'-' }} {{ s.parameters.all|length }} - Edit + + Edit + {% if show_approval and s.status == 'draft' and not s.approval_error %} +
+ {% csrf_token %} + +
+ {% endif %} + {% empty %} No specifications yet