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:
parent
ef6f217e45
commit
d74f0814da
22 changed files with 663 additions and 89 deletions
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -16,6 +16,10 @@ class ProductSpecification(models.Model):
|
||||||
effective_from = models.DateField(null=True, blank=True)
|
effective_from = models.DateField(null=True, blank=True)
|
||||||
effective_to = models.DateField(null=True, blank=True)
|
effective_to = models.DateField(null=True, blank=True)
|
||||||
is_current = models.BooleanField(default=False)
|
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(
|
approved_by = models.ForeignKey(
|
||||||
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
|
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
|
||||||
related_name="specs_approved",
|
related_name="specs_approved",
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ class ProductSpecificationSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ProductSpecification
|
model = ProductSpecification
|
||||||
fields = "__all__"
|
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):
|
class FacilitySerializer(serializers.ModelSerializer):
|
||||||
|
|
|
||||||
|
|
@ -370,6 +370,60 @@ def reject_case(case, user, reason):
|
||||||
return case
|
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):
|
def record_submission(case, user, dds_reference, submission_date):
|
||||||
from apps.compliance.models import DueDiligenceStatementReference
|
from apps.compliance.models import DueDiligenceStatementReference
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,87 @@ class LineageAllocationAPITests(TestCase):
|
||||||
self.assertEqual(IntakeBatchLineage.objects.count(), 2)
|
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):
|
class BuyerIsolationTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.buyer = make_user("iso.buyer@ixg.local", "buyer")
|
self.buyer = make_user("iso.buyer@ixg.local", "buyer")
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,24 @@ class ProductSpecificationViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = ProductSpecificationSerializer
|
serializer_class = ProductSpecificationSerializer
|
||||||
permission_classes = [IsOpsOrAdmin]
|
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):
|
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 [IsAdmin]]
|
||||||
return [permission() for permission in [IsOpsOrAdmin]]
|
return [permission() for permission in [IsOpsOrAdmin]]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ from django.db import connections
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.dateparse import parse_datetime
|
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_PAGES_SQL = (
|
||||||
'SELECT path, title, content, "updatedAt" FROM pages '
|
'SELECT path, title, content, "updatedAt" FROM pages '
|
||||||
|
|
@ -12,7 +12,7 @@ SELECT_PAGES_SQL = (
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
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):
|
def handle(self, *args, **options):
|
||||||
created = 0
|
created = 0
|
||||||
|
|
@ -23,7 +23,7 @@ class Command(BaseCommand):
|
||||||
known_paths = set()
|
known_paths = set()
|
||||||
|
|
||||||
with connections["wikijs"].cursor() as cursor:
|
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()
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
for path, title, content, updated_at_raw in rows:
|
for path, title, content, updated_at_raw in rows:
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import bleach
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from markdown import markdown
|
from markdown import markdown
|
||||||
|
|
||||||
|
from .models import GUIDE_PATH_PREFIX
|
||||||
|
|
||||||
ALLOWED_TAGS = [
|
ALLOWED_TAGS = [
|
||||||
"p",
|
"p",
|
||||||
"br",
|
"br",
|
||||||
|
|
@ -47,9 +49,9 @@ EXTENSIONS = ["tables", "fenced_code", "sane_lists"]
|
||||||
|
|
||||||
WIKI_IMAGE_PREFIXES = ("/buyer-guide/", "/ops-guide/")
|
WIKI_IMAGE_PREFIXES = ("/buyer-guide/", "/ops-guide/")
|
||||||
|
|
||||||
# Links authored in the wiki are absolute: `/en/user-guides/<x>` is a guide that
|
# Links authored in the wiki are absolute: `/en/home/user-guides/<x>` is a guide
|
||||||
# this help centre mirrors, while any other `/en/<x>` is a wiki-only page.
|
# that this help centre mirrors, while any other `/en/<x>` is a wiki-only page.
|
||||||
WIKI_GUIDE_PREFIX = "/en/user-guides/"
|
WIKI_GUIDE_PREFIX = f"/en/{GUIDE_PATH_PREFIX}"
|
||||||
WIKI_PAGE_PREFIX = "/en/"
|
WIKI_PAGE_PREFIX = "/en/"
|
||||||
|
|
||||||
_IMAGE_SRC_RE = re.compile(r'(<img\b[^>]*?\bsrc=")([^"]*)(")', re.IGNORECASE)
|
_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
|
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/...` 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.
|
- any other `/en/<x>` exists only in the wiki -> the wiki public base.
|
||||||
"""
|
"""
|
||||||
path = href.split("#", 1)[0].split("?", 1)[0]
|
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)
|
rewritten = _rewrite_absolute_anchor(prefix, href, suffix, help_root)
|
||||||
return rewritten if rewritten is not None else match.group(0)
|
return rewritten if rewritten is not None else match.group(0)
|
||||||
resolved = posixpath.normpath(posixpath.join(base_folder, href))
|
resolved = posixpath.normpath(posixpath.join(base_folder, href))
|
||||||
if resolved.startswith("user-guides/"):
|
if resolved.startswith(GUIDE_PATH_PREFIX):
|
||||||
suffix_path = resolved[len("user-guides/"):]
|
suffix_path = resolved[len(GUIDE_PATH_PREFIX):]
|
||||||
return f'{prefix}{help_root}{suffix_path}/{suffix}'
|
return f'{prefix}{help_root}{suffix_path}/{suffix}'
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
from django.db import models
|
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 GuidePage(models.Model):
|
||||||
class Audience(models.TextChoices):
|
class Audience(models.TextChoices):
|
||||||
|
|
@ -9,10 +13,10 @@ class GuidePage(models.Model):
|
||||||
ADMIN = "admin", "Administrator"
|
ADMIN = "admin", "Administrator"
|
||||||
|
|
||||||
AUDIENCE_PREFIXES = (
|
AUDIENCE_PREFIXES = (
|
||||||
("user-guides/index", Audience.SHARED),
|
(f"{GUIDE_PATH_PREFIX}index", Audience.SHARED),
|
||||||
("user-guides/buyer/", Audience.BUYER),
|
(f"{GUIDE_PATH_PREFIX}buyer/", Audience.BUYER),
|
||||||
("user-guides/ops/", Audience.OPS),
|
(f"{GUIDE_PATH_PREFIX}ops/", Audience.OPS),
|
||||||
("user-guides/admin/", Audience.ADMIN),
|
(f"{GUIDE_PATH_PREFIX}admin/", Audience.ADMIN),
|
||||||
)
|
)
|
||||||
|
|
||||||
path = models.CharField(max_length=255, unique=True)
|
path = models.CharField(max_length=255, unique=True)
|
||||||
|
|
@ -30,7 +34,9 @@ class GuidePage(models.Model):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def page_slug(self):
|
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):
|
def preview(self, max_chars=180):
|
||||||
for line in self.content.splitlines():
|
for line in self.content.splitlines():
|
||||||
|
|
|
||||||
|
|
@ -30,11 +30,11 @@ def make_page(path, content="# Heading\n\nBody text", title=None):
|
||||||
|
|
||||||
class GuideAudienceFilterTests(TestCase):
|
class GuideAudienceFilterTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.shared = make_page("user-guides/index", "# User Guides\n\nWelcome.", title="Landing Page")
|
self.shared = make_page("home/user-guides/index", "# User Guides\n\nWelcome.", title="Landing Page")
|
||||||
self.buyer = make_page("user-guides/buyer/getting-started", title="Getting Started Guide")
|
self.buyer = make_page("home/user-guides/buyer/getting-started", title="Getting Started Guide")
|
||||||
self.buyer2 = make_page("user-guides/buyer/dashboard", title="Dashboard Guide")
|
self.buyer2 = make_page("home/user-guides/buyer/dashboard", title="Dashboard Guide")
|
||||||
self.ops = make_page("user-guides/ops/batches", title="Ops Batches Guide")
|
self.ops = make_page("home/user-guides/ops/batches", title="Ops Batches Guide")
|
||||||
self.admin = make_page("user-guides/admin/overview", title="Admin Overview Guide")
|
self.admin = make_page("home/user-guides/admin/overview", title="Admin Overview Guide")
|
||||||
self.buyer_user = make_user("buyer@ixg.local", "buyer")
|
self.buyer_user = make_user("buyer@ixg.local", "buyer")
|
||||||
self.ops_user = make_user("ops@ixg.local", "ops")
|
self.ops_user = make_user("ops@ixg.local", "ops")
|
||||||
self.admin_user = make_user("admin@ixg.local", "admin")
|
self.admin_user = make_user("admin@ixg.local", "admin")
|
||||||
|
|
@ -168,7 +168,7 @@ class GuideMarkdownTests(TestCase):
|
||||||
def test_script_tags_are_stripped(self):
|
def test_script_tags_are_stripped(self):
|
||||||
html = render_markdown(
|
html = render_markdown(
|
||||||
"# Title\n\nHello <script>alert('x')</script> world",
|
"# Title\n\nHello <script>alert('x')</script> world",
|
||||||
"user-guides/buyer/test",
|
"home/user-guides/buyer/test",
|
||||||
"/portal/help/",
|
"/portal/help/",
|
||||||
)
|
)
|
||||||
self.assertNotIn("<script", html)
|
self.assertNotIn("<script", html)
|
||||||
|
|
@ -176,7 +176,7 @@ class GuideMarkdownTests(TestCase):
|
||||||
def test_tables_and_heading_render(self):
|
def test_tables_and_heading_render(self):
|
||||||
html = render_markdown(
|
html = render_markdown(
|
||||||
"| A | B |\n|---|---|\n| 1 | 2 |",
|
"| A | B |\n|---|---|\n| 1 | 2 |",
|
||||||
"user-guides/buyer/test",
|
"home/user-guides/buyer/test",
|
||||||
"/portal/help/",
|
"/portal/help/",
|
||||||
)
|
)
|
||||||
self.assertIn("<table", html)
|
self.assertIn("<table", html)
|
||||||
|
|
@ -186,7 +186,7 @@ class GuideMarkdownTests(TestCase):
|
||||||
def test_same_folder_link_rewritten_to_help_root(self):
|
def test_same_folder_link_rewritten_to_help_root(self):
|
||||||
html = render_markdown(
|
html = render_markdown(
|
||||||
"[Compliance guide](compliance)",
|
"[Compliance guide](compliance)",
|
||||||
"user-guides/buyer/getting-started",
|
"home/user-guides/buyer/getting-started",
|
||||||
"/portal/help/",
|
"/portal/help/",
|
||||||
)
|
)
|
||||||
self.assertIn('href="/portal/help/buyer/compliance/"', html)
|
self.assertIn('href="/portal/help/buyer/compliance/"', html)
|
||||||
|
|
@ -195,7 +195,7 @@ class GuideMarkdownTests(TestCase):
|
||||||
def test_index_relative_link_rewritten(self):
|
def test_index_relative_link_rewritten(self):
|
||||||
html = render_markdown(
|
html = render_markdown(
|
||||||
"[Getting started](buyer/getting-started)",
|
"[Getting started](buyer/getting-started)",
|
||||||
"user-guides/index",
|
"home/user-guides/index",
|
||||||
"/ops/help/",
|
"/ops/help/",
|
||||||
)
|
)
|
||||||
self.assertIn('href="/ops/help/buyer/getting-started/"', html)
|
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):
|
def test_wiki_image_path_rewritten_to_public_base(self):
|
||||||
html = render_markdown(
|
html = render_markdown(
|
||||||
"",
|
"",
|
||||||
"user-guides/buyer/test",
|
"home/user-guides/buyer/test",
|
||||||
"/portal/help/",
|
"/portal/help/",
|
||||||
)
|
)
|
||||||
self.assertIn('src="http://wiki.test/buyer-guide/buyer-dashboard.png"', html)
|
self.assertIn('src="http://wiki.test/buyer-guide/buyer-dashboard.png"', html)
|
||||||
|
|
@ -213,7 +213,7 @@ class GuideMarkdownTests(TestCase):
|
||||||
html = render_markdown(
|
html = render_markdown(
|
||||||
"[Top](#buyer-portal-guides)\n\n[Email](mailto:a@b.c)\n\n"
|
"[Top](#buyer-portal-guides)\n\n[Email](mailto:a@b.c)\n\n"
|
||||||
"[Elsewhere](https://example.com/x)",
|
"[Elsewhere](https://example.com/x)",
|
||||||
"user-guides/index",
|
"home/user-guides/index",
|
||||||
"/portal/help/",
|
"/portal/help/",
|
||||||
)
|
)
|
||||||
self.assertIn('href="#buyer-portal-guides"', html)
|
self.assertIn('href="#buyer-portal-guides"', html)
|
||||||
|
|
@ -222,16 +222,16 @@ class GuideMarkdownTests(TestCase):
|
||||||
|
|
||||||
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
||||||
def test_absolute_wiki_guide_link_rewritten_to_help_path(self):
|
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
|
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
|
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.
|
one page serve both surfaces.
|
||||||
"""
|
"""
|
||||||
html = render_markdown(
|
html = render_markdown(
|
||||||
"[Step 3](/en/user-guides/ops/walkthrough/intakes)",
|
"[Step 3](/en/home/user-guides/ops/walkthrough/intakes)",
|
||||||
"user-guides/ops/walkthrough",
|
"home/user-guides/ops/walkthrough",
|
||||||
"/ops/help/",
|
"/ops/help/",
|
||||||
)
|
)
|
||||||
self.assertIn('href="/ops/help/ops/walkthrough/intakes/"', html)
|
self.assertIn('href="/ops/help/ops/walkthrough/intakes/"', html)
|
||||||
|
|
@ -239,8 +239,8 @@ class GuideMarkdownTests(TestCase):
|
||||||
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
||||||
def test_absolute_wiki_guide_link_to_an_index_page(self):
|
def test_absolute_wiki_guide_link_to_an_index_page(self):
|
||||||
html = render_markdown(
|
html = render_markdown(
|
||||||
"[Back to the index](/en/user-guides/index)",
|
"[Back to the index](/en/home/user-guides/index)",
|
||||||
"user-guides/buyer/getting-started",
|
"home/user-guides/buyer/getting-started",
|
||||||
"/portal/help/",
|
"/portal/help/",
|
||||||
)
|
)
|
||||||
self.assertIn('href="/portal/help/index/"', html)
|
self.assertIn('href="/portal/help/index/"', html)
|
||||||
|
|
@ -253,14 +253,14 @@ class GuideMarkdownTests(TestCase):
|
||||||
"""
|
"""
|
||||||
html = render_markdown(
|
html = render_markdown(
|
||||||
"[Development Plan](/en/home/development/plan)",
|
"[Development Plan](/en/home/development/plan)",
|
||||||
"user-guides/index",
|
"home/user-guides/index",
|
||||||
"/ops/help/",
|
"/ops/help/",
|
||||||
)
|
)
|
||||||
self.assertIn('href="http://wiki.test/en/home/development/plan"', html)
|
self.assertIn('href="http://wiki.test/en/home/development/plan"', html)
|
||||||
|
|
||||||
def test_absolute_app_paths_left_alone(self):
|
def test_absolute_app_paths_left_alone(self):
|
||||||
html = render_markdown(
|
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)
|
self.assertIn('href="/ops/batches/"', html)
|
||||||
|
|
||||||
|
|
@ -285,10 +285,10 @@ class FakeCursor:
|
||||||
class SyncGuidesCommandTests(TestCase):
|
class SyncGuidesCommandTests(TestCase):
|
||||||
def test_sync_maps_audience_and_upserts(self):
|
def test_sync_maps_audience_and_upserts(self):
|
||||||
rows = [
|
rows = [
|
||||||
("user-guides/index", "User Guides", "# landing", "2026-08-07T13:14:15.809Z"),
|
("home/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"),
|
("home/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"),
|
("home/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/admin/overview", "Admin Overview", "# a", "2026-08-07T13:52:15.763Z"),
|
||||||
]
|
]
|
||||||
with mock.patch(
|
with mock.patch(
|
||||||
"apps.guides.management.commands.sync_guides.connections"
|
"apps.guides.management.commands.sync_guides.connections"
|
||||||
|
|
@ -298,23 +298,23 @@ class SyncGuidesCommandTests(TestCase):
|
||||||
|
|
||||||
self.assertEqual(GuidePage.objects.count(), 4)
|
self.assertEqual(GuidePage.objects.count(), 4)
|
||||||
self.assertEqual(
|
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(
|
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,
|
GuidePage.Audience.BUYER,
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
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(
|
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):
|
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 = [
|
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(
|
with mock.patch(
|
||||||
"apps.guides.management.commands.sync_guides.connections"
|
"apps.guides.management.commands.sync_guides.connections"
|
||||||
|
|
@ -322,6 +322,6 @@ class SyncGuidesCommandTests(TestCase):
|
||||||
mock_connections["wikijs"].cursor.return_value = FakeCursor(rows)
|
mock_connections["wikijs"].cursor.return_value = FakeCursor(rows)
|
||||||
call_command("sync_guides")
|
call_command("sync_guides")
|
||||||
|
|
||||||
self.assertFalse(GuidePage.objects.filter(path="user-guides/buyer/old").exists())
|
self.assertFalse(GuidePage.objects.filter(path="home/user-guides/buyer/old").exists())
|
||||||
page = GuidePage.objects.get(path="user-guides/buyer/getting-started")
|
page = GuidePage.objects.get(path="home/user-guides/buyer/getting-started")
|
||||||
self.assertEqual(page.content, "# fresh")
|
self.assertEqual(page.content, "# fresh")
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
|
||||||
from django.views.generic import DetailView, ListView
|
from django.views.generic import DetailView, ListView
|
||||||
|
|
||||||
from .markdown import render_markdown
|
from .markdown import render_markdown
|
||||||
from .models import GuidePage, allowed_audiences
|
from .models import GUIDE_PATH_PREFIX, GuidePage, allowed_audiences
|
||||||
|
|
||||||
|
|
||||||
class GuideAccessMixin:
|
class GuideAccessMixin:
|
||||||
|
|
@ -51,7 +51,7 @@ class GuideDetailView(LoginRequiredMixin, GuideAccessMixin, DetailView):
|
||||||
context_object_name = "guide"
|
context_object_name = "guide"
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
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)
|
page = get_object_or_404(GuidePage, path=path)
|
||||||
if page.audience not in self.get_allowed_audiences():
|
if page.audience not in self.get_allowed_audiences():
|
||||||
raise Http404("This guide is not available in this portal.")
|
raise Http404("This guide is not available in this portal.")
|
||||||
|
|
|
||||||
|
|
@ -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):
|
class OriginZoneForm(forms.ModelForm):
|
||||||
geometry = forms.JSONField(
|
geometry = OptionalGeoJSONField(
|
||||||
required=False,
|
required=False,
|
||||||
label="Geometry (GeoJSON)",
|
label="Geometry (GeoJSON)",
|
||||||
help_text=(
|
help_text=(
|
||||||
"The zone's polygon as GeoJSON, for example "
|
"The zone's polygon as GeoJSON, for example "
|
||||||
'{"type": "Polygon", "coordinates": [[[7.60, 12.99], ...]]}. '
|
'{"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."},
|
error_messages={"invalid": "Enter valid GeoJSON, or leave this field blank."},
|
||||||
widget=forms.Textarea(
|
widget=forms.Textarea(
|
||||||
|
|
|
||||||
|
|
@ -913,6 +913,7 @@ class RegisterAccessTests(ComplianceRegisterBase):
|
||||||
"/ops/facilities/", "/ops/facilities/new/",
|
"/ops/facilities/", "/ops/facilities/new/",
|
||||||
f"/ops/facilities/{self.facility.pk}/edit/",
|
f"/ops/facilities/{self.facility.pk}/edit/",
|
||||||
"/ops/origin-zones/", "/ops/origin-zones/new/",
|
"/ops/origin-zones/", "/ops/origin-zones/new/",
|
||||||
|
f"/ops/origin-zones/{self.zone.pk}/",
|
||||||
f"/ops/origin-zones/{self.zone.pk}/edit/",
|
f"/ops/origin-zones/{self.zone.pk}/edit/",
|
||||||
"/ops/collector-groups/", "/ops/collector-groups/new/",
|
"/ops/collector-groups/", "/ops/collector-groups/new/",
|
||||||
f"/ops/collector-groups/{self.group.pk}/edit/",
|
f"/ops/collector-groups/{self.group.pk}/edit/",
|
||||||
|
|
@ -1075,6 +1076,38 @@ class OriginZoneRegisterTests(ComplianceRegisterBase):
|
||||||
self.assertIn("No polygon coordinates are stored", html)
|
self.assertIn("No polygon coordinates are stored", html)
|
||||||
self.assertNotIn('id="zone-map"', 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):
|
class CollectorGroupRegisterTests(ComplianceRegisterBase):
|
||||||
def payload(self, **overrides):
|
def payload(self, **overrides):
|
||||||
|
|
@ -1135,6 +1168,25 @@ class EUDRCaseChildBase(ComplianceRegisterBase):
|
||||||
return DueDiligenceCase.objects.create(batch=batch)
|
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):
|
class EUDREvidenceTests(EUDRCaseChildBase):
|
||||||
def payload(self, **overrides):
|
def payload(self, **overrides):
|
||||||
data = {
|
data = {
|
||||||
|
|
@ -1373,6 +1425,7 @@ class SpecificationCreateTests(SpecificationBase):
|
||||||
spec = ProductSpecification.objects.get(version="2.0")
|
spec = ProductSpecification.objects.get(version="2.0")
|
||||||
self.assertEqual(spec.status, ProductSpecification.Status.DRAFT)
|
self.assertEqual(spec.status, ProductSpecification.Status.DRAFT)
|
||||||
self.assertFalse(spec.is_current)
|
self.assertFalse(spec.is_current)
|
||||||
|
self.assertEqual(spec.created_by, self.ops)
|
||||||
self.assertEqual(spec.parameters.count(), 1)
|
self.assertEqual(spec.parameters.count(), 1)
|
||||||
self.assertEqual(spec.parameters.first().parameter, "moisture")
|
self.assertEqual(spec.parameters.first().parameter, "moisture")
|
||||||
|
|
||||||
|
|
@ -1474,3 +1527,91 @@ class SpecificationEditTests(SpecificationBase):
|
||||||
def test_a_buyer_cannot_post_a_specification(self):
|
def test_a_buyer_cannot_post_a_specification(self):
|
||||||
r = self.client_buyer.post("/ops/specifications/new/", self.create_payload())
|
r = self.client_buyer.post("/ops/specifications/new/", self.create_payload())
|
||||||
self.assertEqual(r.status_code, 403)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ urlpatterns = [
|
||||||
path('facilities/<uuid:pk>/edit/', views.FacilityUpdateView.as_view(), name='facility_update'),
|
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/', views.OriginZoneListView.as_view(), name='origin_zone_list'),
|
||||||
path('origin-zones/new/', views.OriginZoneCreateView.as_view(), name='origin_zone_create'),
|
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('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/', views.CollectorGroupListView.as_view(), name='collector_group_list'),
|
||||||
path('collector-groups/new/', views.CollectorGroupCreateView.as_view(), name='collector_group_create'),
|
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/', views.SpecificationListView.as_view(), name='specification_list'),
|
||||||
path('specifications/new/', views.SpecificationCreateView.as_view(), name='specification_create'),
|
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>/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/', views.QCListView.as_view(), name='qc_list'),
|
||||||
path('qc/<uuid:pk>/', views.QCDetailView.as_view(), name='qc_detail'),
|
path('qc/<uuid:pk>/', views.QCDetailView.as_view(), name='qc_detail'),
|
||||||
path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'),
|
path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'),
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,12 @@ class OpsRequiredMixin(UserPassesTestMixin):
|
||||||
return user.is_authenticated and (user.role in ('ops', 'admin') or user.is_superuser)
|
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):
|
class DashboardView(OpsRequiredMixin, TemplateView):
|
||||||
template_name = 'ops/dashboard.html'
|
template_name = 'ops/dashboard.html'
|
||||||
|
|
||||||
|
|
@ -917,6 +923,16 @@ class OriginZoneListView(OpsRequiredMixin, ListView):
|
||||||
return ctx
|
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:
|
class OriginZoneFormContextMixin:
|
||||||
model = OriginZone
|
model = OriginZone
|
||||||
form_class = OriginZoneForm
|
form_class = OriginZoneForm
|
||||||
|
|
@ -925,13 +941,7 @@ class OriginZoneFormContextMixin:
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
ctx['cancel_url'] = reverse('ops:origin_zone_list')
|
ctx['cancel_url'] = reverse('ops:origin_zone_list')
|
||||||
ctx['show_zone_map'] = True
|
return add_zone_map_context(ctx, getattr(self, 'object', None))
|
||||||
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
|
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
apply_zone_verification(
|
apply_zone_verification(
|
||||||
|
|
@ -942,6 +952,26 @@ class OriginZoneFormContextMixin:
|
||||||
return super().form_valid(form)
|
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):
|
class OriginZoneCreateView(OriginZoneFormContextMixin, OpsRequiredMixin, CreateView):
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
|
@ -954,7 +984,7 @@ class OriginZoneCreateView(OriginZoneFormContextMixin, OpsRequiredMixin, CreateV
|
||||||
messages.success(
|
messages.success(
|
||||||
self.request,
|
self.request,
|
||||||
f"Origin zone {self.object.zone_code} created. "
|
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
|
return response
|
||||||
|
|
||||||
|
|
@ -1183,9 +1213,9 @@ class StatementReferenceUpdateView(CaseChildMixin, UpdateView):
|
||||||
#
|
#
|
||||||
# A specification is the set of limits every QC result is judged against. The
|
# 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
|
# 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
|
# status, is_current, approved_by and approved_at are not on the form: approval
|
||||||
# are not on the form, so a portal-created specification is a draft and cannot
|
# is a separate administrator action in apps.compliance.services, and the
|
||||||
# be approved here. See apps/ops/forms.py for the reasoning.
|
# creator may not approve their own specification (four-eyes).
|
||||||
|
|
||||||
|
|
||||||
class SpecificationListView(OpsRequiredMixin, ListView):
|
class SpecificationListView(OpsRequiredMixin, ListView):
|
||||||
|
|
@ -1211,6 +1241,10 @@ class SpecificationListView(OpsRequiredMixin, ListView):
|
||||||
ctx['status_choices'] = ProductSpecification.Status.choices
|
ctx['status_choices'] = ProductSpecification.Status.choices
|
||||||
ctx['current_product'] = self.request.GET.get('product', '')
|
ctx['current_product'] = self.request.GET.get('product', '')
|
||||||
ctx['current_status'] = self.request.GET.get('status', '')
|
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
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1234,13 +1268,27 @@ class SpecificationFormContextMixin:
|
||||||
and spec.pk
|
and spec.pk
|
||||||
and spec.status != ProductSpecification.Status.DRAFT
|
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
|
return ctx
|
||||||
|
|
||||||
def forms_valid(self, form, formset):
|
def forms_valid(self, form, formset):
|
||||||
self.object = form.save(commit=False)
|
self.object = form.save(commit=False)
|
||||||
if self.object._state.adding:
|
if self.object._state.adding:
|
||||||
# The approval fields are not on the form; a portal-created spec is
|
# 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.status = ProductSpecification.Status.DRAFT
|
||||||
self.object.is_current = False
|
self.object.is_current = False
|
||||||
self.object.save()
|
self.object.save()
|
||||||
|
|
@ -1274,6 +1322,24 @@ class SpecificationCreateView(SpecificationFormContextMixin, OpsRequiredMixin, C
|
||||||
return self.forms_invalid(form, formset)
|
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):
|
class SpecificationUpdateView(SpecificationFormContextMixin, OpsRequiredMixin, UpdateView):
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
ctx = super().get_context_data(**kwargs)
|
ctx = super().get_context_data(**kwargs)
|
||||||
|
|
|
||||||
|
|
@ -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 '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 '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 '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 '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 '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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
{% extends 'ops/base_ops.html' %}
|
{% extends 'ops/base_ops.html' %}
|
||||||
{% block title %}EUDR Case - IXG Operations{% endblock %}
|
{% block title %}Due Diligence Case - IXG Operations{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="page-header d-flex justify-content-between align-items-center">
|
<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>
|
<a href="{% url 'ops:eudr_list' %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -11,10 +11,15 @@
|
||||||
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
|
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
|
||||||
{% endif %}
|
{% 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="row g-3">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<div class="stat-card mb-3">
|
<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 %}
|
{% if case.completeness_results %}
|
||||||
<table class="table table-sm mb-0">
|
<table class="table table-sm mb-0">
|
||||||
<thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>
|
<thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>
|
||||||
|
|
@ -34,7 +39,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-card mb-3">
|
<div class="stat-card mb-3">
|
||||||
<h6 class="mb-3">Intake Lineage</h6>
|
<h6 class="mb-3">Intake lineage (origin)</h6>
|
||||||
{% if lineage %}
|
{% if lineage %}
|
||||||
<table class="table table-sm mb-0">
|
<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>
|
<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="stat-card mb-3">
|
||||||
<div class="d-flex justify-content-between align-items-center 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>
|
<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>
|
</div>
|
||||||
{% for e in evidence %}
|
{% for e in evidence %}
|
||||||
|
|
@ -75,7 +80,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-card mb-3">
|
<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 %}
|
{% for r in risk_assessments %}
|
||||||
<div class="border-bottom pb-2 mb-2">
|
<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>
|
<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="stat-card mb-3">
|
||||||
<div class="d-flex justify-content-between align-items-center 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>
|
<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>
|
</div>
|
||||||
{% for m in mitigation_actions %}
|
{% for m in mitigation_actions %}
|
||||||
|
|
@ -104,7 +109,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-card">
|
<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 %}
|
{% for s in statement_references %}
|
||||||
<div class="border-bottom pb-2 mb-2">
|
<div class="border-bottom pb-2 mb-2">
|
||||||
<span class="badge bg-secondary">{{ s.reconciliation_status }}</span>
|
<span class="badge bg-secondary">{{ s.reconciliation_status }}</span>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
{% extends 'ops/base_ops.html' %}
|
{% extends 'ops/base_ops.html' %}
|
||||||
{% block title %}EUDR Compliance - IXG Operations{% endblock %}
|
{% block title %}Due Diligence (EUDR) - IXG Operations{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="page-header d-flex justify-content-between align-items-center">
|
<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>
|
<span class="text-secondary">{{ page_obj.paginator.count }} total</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-light border py-2 small">
|
<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
|
<i class="bi bi-info-circle"></i> Each row is a <strong>due diligence case</strong> for a batch. Shea butter is not
|
||||||
evidence and buyer assurance, not an EU-mandated filing.
|
listed in EUDR Annex I; this tracker provides due-diligence evidence and buyer assurance, not an EU-mandated filing.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
|
|
|
||||||
125
templates/ops/origin_zone_detail.html
Normal file
125
templates/ops/origin_zone_detail.html
Normal 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 }}
|
||||||
|
· {{ zone.country }}{% if zone.region %} · {{ 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 %}
|
||||||
|
|
@ -49,7 +49,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for z in zones %}
|
{% for z in zones %}
|
||||||
<tr>
|
<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.facility.name|default:'-' }}</td>
|
||||||
<td>{{ z.country }}</td>
|
<td>{{ z.country }}</td>
|
||||||
<td>{{ z.region|default:'-' }}</td>
|
<td>{{ z.region|default:'-' }}</td>
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,7 @@
|
||||||
{% if editing_approved %}
|
{% if editing_approved %}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
<strong>This specification is not a draft.</strong> Its parameters are the limits QC results
|
<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
|
are judged against, so editing them changes those limits.
|
||||||
portal yet — it is held until the approval control is decided.
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|
@ -119,13 +118,43 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stat-card">
|
<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">
|
<p class="small mb-0">
|
||||||
Creating and editing is available here, but the portal does not approve a
|
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 %}.
|
||||||
specification. A new one is saved as a draft. Approving it — the step that makes
|
{% if object.is_current %}It is the current specification for this product.{% endif %}
|
||||||
its limits the ones QC is judged against — waits for the approval control to be
|
|
||||||
decided.
|
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@
|
||||||
|
|
||||||
<div class="alert alert-light border py-2 small">
|
<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
|
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
|
create and edit one; <strong>approval is a separate administrator action</strong>, and
|
||||||
is a draft and cannot be approved from the portal yet.
|
nobody may approve a specification they created (four-eyes). A new specification is
|
||||||
|
saved as a draft until an administrator approves it.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="filter-bar">
|
<div class="filter-bar">
|
||||||
|
|
@ -72,7 +73,16 @@
|
||||||
<td>{{ s.effective_from|date:'d/m/Y'|default:'-' }}</td>
|
<td>{{ s.effective_from|date:'d/m/Y'|default:'-' }}</td>
|
||||||
<td>{{ s.effective_to|date:'d/m/Y'|default:'-' }}</td>
|
<td>{{ s.effective_to|date:'d/m/Y'|default:'-' }}</td>
|
||||||
<td>{{ s.parameters.all|length }}</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>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="8" class="text-muted text-center py-3">No specifications yet</td></tr>
|
<tr><td colspan="8" class="text-muted text-center py-3">No specifications yet</td></tr>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue