Add the compliance registers to the ops portal
Facility, origin zone and collector group could only be registered in the Django admin, which left the portal unable to onboard a new supply site, zone or group. Add list/create/edit screens for all three, and create/edit for mitigation actions, EUDR evidence and external-submission references on a due-diligence case. The origin-zone form keeps the map a view of the stored geometry, not a check of it: the map wording is unchanged and geometry is only required to be valid JSON. Verification is stamped from the acting user when the status moves to verified/rejected, and cleared when it returns to unverified. Nothing is removed from the admin; it stays as the superuser fallback. Adds an nginx /tiles/ route on the ops host so the map imagery loads there too (the tile view is root-mounted but ops remaps / to /ops/).
This commit is contained in:
parent
243d4eefcd
commit
f6aedc0002
10 changed files with 1481 additions and 3 deletions
|
|
@ -28,9 +28,12 @@ from apps.batches.models import ProcessingBatch
|
|||
from apps.compliance import services
|
||||
from apps.compliance.models import (
|
||||
CollectorGroup,
|
||||
DueDiligenceStatementReference,
|
||||
EUDREvidence,
|
||||
Facility,
|
||||
HarvestIntake,
|
||||
IntakeBatchLineage,
|
||||
MitigationAction,
|
||||
OriginZone,
|
||||
)
|
||||
from apps.shipments.models import Shipment
|
||||
|
|
@ -656,3 +659,286 @@ class ShipmentForm(forms.ModelForm):
|
|||
),
|
||||
)
|
||||
return cleaned
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compliance registers
|
||||
#
|
||||
# The facility, origin-zone and collector-group registers, and the evidence,
|
||||
# mitigation and statement records on a due-diligence case, used to be editable
|
||||
# only in the Django administration area. These forms bring that data entry into
|
||||
# the ops portal. They mirror the admin's fields; nothing is removed from admin.
|
||||
#
|
||||
# The origin-zone form deliberately keeps the map a *view* of the stored
|
||||
# geometry rather than a check of it — it does not validate the shape and it
|
||||
# does not verify the zone. The form only insists that the geometry, if given,
|
||||
# is valid JSON, because the column is JSON and the map cannot draw a broken
|
||||
# value. Geometry validation and the two-step verification are separate work.
|
||||
|
||||
|
||||
class FacilityForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Facility
|
||||
fields = ["name", "country", "region", "address", "facility_type", "is_active"]
|
||||
labels = {
|
||||
"name": "Facility name",
|
||||
"country": "Country",
|
||||
"region": "Region",
|
||||
"address": "Address",
|
||||
"facility_type": "Facility type",
|
||||
"is_active": "Active",
|
||||
}
|
||||
help_texts = {
|
||||
"name": "The site's name, as it should appear on records.",
|
||||
"country": "The country the site operates in.",
|
||||
"region": "The state or region, if it helps identify the site.",
|
||||
"address": "Optional. The site's address.",
|
||||
"facility_type": "Production sites collect; processing sites handle the shea.",
|
||||
"is_active": "Uncheck to retire a site without deleting its history.",
|
||||
}
|
||||
widgets = {
|
||||
"name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"country": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"region": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"address": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
"facility_type": forms.Select(attrs={"class": "form-select"}),
|
||||
"is_active": forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
||||
}
|
||||
|
||||
|
||||
class OriginZoneForm(forms.ModelForm):
|
||||
geometry = forms.JSONField(
|
||||
required=False,
|
||||
label="Geometry (GeoJSON)",
|
||||
help_text=(
|
||||
"The zone's polygon as GeoJSON, for example "
|
||||
'{"type": "Polygon", "coordinates": [[[7.60, 12.99], ...]]}. '
|
||||
"Stored as given: the map below draws it, it does not check the shape."
|
||||
),
|
||||
error_messages={"invalid": "Enter valid GeoJSON, or leave this field blank."},
|
||||
widget=forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 6, "spellcheck": "false"}
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = OriginZone
|
||||
fields = [
|
||||
"facility",
|
||||
"zone_code",
|
||||
"country",
|
||||
"region",
|
||||
"geometry",
|
||||
"coordinate_source",
|
||||
"coordinates_collected_by",
|
||||
"coordinates_collected_at",
|
||||
"evidence_file",
|
||||
"verification_status",
|
||||
]
|
||||
labels = {
|
||||
"facility": "Facility",
|
||||
"zone_code": "Zone code",
|
||||
"country": "Country",
|
||||
"region": "Region",
|
||||
"coordinate_source": "Coordinate source",
|
||||
"coordinates_collected_by": "Coordinates collected by",
|
||||
"coordinates_collected_at": "Coordinates collected on",
|
||||
"evidence_file": "Supporting file name",
|
||||
"verification_status": "Verification status",
|
||||
}
|
||||
help_texts = {
|
||||
"zone_code": "Must be unique. The short code the zone is known by.",
|
||||
"country": "The country the zone lies in.",
|
||||
"region": "The state or region.",
|
||||
"coordinate_source": "Where the coordinates came from, e.g. a survey or a map.",
|
||||
"coordinates_collected_by": "Who collected the coordinates.",
|
||||
"coordinates_collected_at": "The date the coordinates were collected.",
|
||||
"evidence_file": "The name of the supporting record. Files are not uploaded by the platform yet, so this is the name it is filed under.",
|
||||
"verification_status": "Unverified until the zone has been checked. Setting it to Verified or Rejected records who did so and when.",
|
||||
}
|
||||
widgets = {
|
||||
"facility": forms.Select(attrs={"class": "form-select"}),
|
||||
"zone_code": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"country": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"region": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"coordinate_source": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"coordinates_collected_by": forms.TextInput(
|
||||
attrs={"class": "form-control"}
|
||||
),
|
||||
"coordinates_collected_at": forms.DateInput(
|
||||
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"evidence_file": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"verification_status": forms.Select(attrs={"class": "form-select"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for name in ("facility", "zone_code", "country"):
|
||||
self.fields[name].required = True
|
||||
|
||||
def clean_geometry(self):
|
||||
# An empty field must become {}, not None: the column is a non-null JSON
|
||||
# field and a zone may legitimately have no polygon stored yet.
|
||||
value = self.cleaned_data.get("geometry")
|
||||
return value if value not in (None, "") else {}
|
||||
|
||||
def clean_zone_code(self):
|
||||
code = (self.cleaned_data.get("zone_code") or "").strip()
|
||||
if not code:
|
||||
raise forms.ValidationError("A zone code is required.")
|
||||
clashes = OriginZone.objects.filter(zone_code__iexact=code)
|
||||
if self.instance.pk:
|
||||
clashes = clashes.exclude(pk=self.instance.pk)
|
||||
if clashes.exists():
|
||||
raise forms.ValidationError(
|
||||
f"Zone {code} already exists. Zone codes must be unique."
|
||||
)
|
||||
return code
|
||||
|
||||
|
||||
class CollectorGroupForm(forms.ModelForm):
|
||||
origin_zone = ZoneChoiceField(
|
||||
queryset=OriginZone.objects.order_by("zone_code"),
|
||||
label="Origin zone",
|
||||
required=False,
|
||||
empty_label="No origin zone set",
|
||||
help_text=(
|
||||
"The zone this group normally collects from. Optional, and it can be "
|
||||
"changed if the group draws from more than one area."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CollectorGroup
|
||||
fields = ["name", "contact_reference", "origin_zone", "notes"]
|
||||
labels = {
|
||||
"name": "Group name",
|
||||
"contact_reference": "Contact reference",
|
||||
"notes": "Notes",
|
||||
}
|
||||
help_texts = {
|
||||
"name": "The group's name, as it should appear on records.",
|
||||
"contact_reference": "Optional. A reference for the group's contact.",
|
||||
"notes": "Anything the next person needs to know about this group.",
|
||||
}
|
||||
widgets = {
|
||||
"name": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"contact_reference": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"notes": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["name"].required = True
|
||||
|
||||
|
||||
class MitigationActionForm(forms.ModelForm):
|
||||
"""Add or correct a mitigation action on a due-diligence case."""
|
||||
|
||||
class Meta:
|
||||
model = MitigationAction
|
||||
fields = ["action", "status", "owner", "due_date", "evidence_file"]
|
||||
labels = {
|
||||
"action": "Action",
|
||||
"status": "Status",
|
||||
"owner": "Owner",
|
||||
"due_date": "Due date",
|
||||
"evidence_file": "Supporting file name",
|
||||
}
|
||||
help_texts = {
|
||||
"action": "What will be done to address the risk.",
|
||||
"status": "Open until the action has been carried out; Verified once it has been checked.",
|
||||
"owner": "Who is responsible for the action.",
|
||||
"due_date": "When it is expected to be done.",
|
||||
"evidence_file": "The name of the supporting record. Files are not uploaded by the platform yet, so this is the name it is filed under.",
|
||||
}
|
||||
widgets = {
|
||||
"action": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
"status": forms.Select(attrs={"class": "form-select"}),
|
||||
"owner": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"due_date": forms.DateInput(
|
||||
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"evidence_file": forms.TextInput(attrs={"class": "form-control"}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["action"].required = True
|
||||
self.fields["status"].required = True
|
||||
|
||||
|
||||
class EUDREvidenceForm(forms.ModelForm):
|
||||
"""Attach an item of evidence to a due-diligence case.
|
||||
|
||||
``file`` is a stored reference (a file name or path), not an uploaded file:
|
||||
the platform has no upload screen yet, so the evidence record points at the
|
||||
document the way the admin form always has. A real upload is separate work.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = EUDREvidence
|
||||
fields = ["evidence_type", "file", "source", "collected_at", "notes"]
|
||||
labels = {
|
||||
"evidence_type": "Evidence type",
|
||||
"file": "File reference",
|
||||
"source": "Source",
|
||||
"collected_at": "Collected on",
|
||||
"notes": "Notes",
|
||||
}
|
||||
help_texts = {
|
||||
"evidence_type": "Geolocation, harvest document and supplier identity are the three the completeness check requires.",
|
||||
"file": "The name of the file the evidence is stored under.",
|
||||
"source": "Where the evidence came from.",
|
||||
"collected_at": "The date the evidence was collected.",
|
||||
"notes": "Anything the next person needs to know about this evidence.",
|
||||
}
|
||||
widgets = {
|
||||
"evidence_type": forms.Select(attrs={"class": "form-select"}),
|
||||
"file": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"source": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"collected_at": forms.DateInput(
|
||||
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"notes": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["evidence_type"].required = True
|
||||
self.fields["file"].required = True
|
||||
|
||||
|
||||
class StatementReferenceForm(forms.ModelForm):
|
||||
"""Correct an external-submission (DDS) reference on a case."""
|
||||
|
||||
class Meta:
|
||||
model = DueDiligenceStatementReference
|
||||
fields = ["dds_reference", "submission_date", "reconciliation_status", "notes"]
|
||||
labels = {
|
||||
"dds_reference": "DDS reference",
|
||||
"submission_date": "Submission date",
|
||||
"reconciliation_status": "Reconciliation status",
|
||||
"notes": "Notes",
|
||||
}
|
||||
help_texts = {
|
||||
"dds_reference": "The reference the external submission was given.",
|
||||
"submission_date": "The date it was submitted.",
|
||||
"reconciliation_status": "Pending until the submission has been reconciled against the case.",
|
||||
"notes": "Anything the next person needs to know about this submission.",
|
||||
}
|
||||
widgets = {
|
||||
"dds_reference": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"submission_date": forms.DateInput(
|
||||
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
|
||||
),
|
||||
"reconciliation_status": forms.Select(attrs={"class": "form-select"}),
|
||||
"notes": forms.Textarea(attrs={"class": "form-control", "rows": 3}),
|
||||
}
|
||||
|
||||
def clean_dds_reference(self):
|
||||
reference = (self.cleaned_data.get("dds_reference") or "").strip()
|
||||
if not reference:
|
||||
raise forms.ValidationError("A DDS reference is required.")
|
||||
return reference
|
||||
|
|
|
|||
|
|
@ -12,13 +12,18 @@ from decimal import Decimal
|
|||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
from apps.compliance.models import (
|
||||
CollectorGroup,
|
||||
DueDiligenceCase,
|
||||
DueDiligenceStatementReference,
|
||||
EUDREvidence,
|
||||
Facility,
|
||||
HarvestIntake,
|
||||
IntakeBatchLineage,
|
||||
MitigationAction,
|
||||
OriginZone,
|
||||
)
|
||||
from apps.shipments.models import Shipment
|
||||
|
|
@ -845,3 +850,409 @@ class ShipmentEditTests(ShipmentTestCase):
|
|||
r = self.client_ops.get(f"/ops/shipments/{self.shipment.pk}/edit/")
|
||||
self.assertContains(r, 'value="2026-09-15"')
|
||||
self.assertContains(r, 'value="2026-10-15"')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compliance registers
|
||||
#
|
||||
# Facility, origin zone and collector group used to be admin-only. These tests
|
||||
# pin that operations can now register them from the portal, that only
|
||||
# operations may, and that the origin-zone map is a view of the stored geometry
|
||||
# rather than a check of it.
|
||||
|
||||
|
||||
class ComplianceRegisterBase(TestCase):
|
||||
def setUp(self):
|
||||
self.ops = User.objects.create_user(
|
||||
email="reg-ops@ixg.local", password="pass1234", role="ops",
|
||||
full_name="Register Officer", is_staff=True,
|
||||
)
|
||||
self.buyer = User.objects.create_user(
|
||||
email="reg-buyer@ixg.local", password="pass1234", role="buyer",
|
||||
full_name="Register Buyer", is_staff=True,
|
||||
)
|
||||
self.facility = Facility.objects.create(
|
||||
name="IXG Register Facility", country="Nigeria",
|
||||
region="Katsina State",
|
||||
facility_type=Facility.FacilityType.PROCESSING,
|
||||
)
|
||||
self.zone = OriginZone.objects.create(
|
||||
facility=self.facility, zone_code="NG-KT-RZ01", country="Nigeria",
|
||||
region="Katsina State", verification_status="verified",
|
||||
verified_by=self.ops, verified_at=timezone.now(),
|
||||
)
|
||||
self.group = CollectorGroup.objects.create(
|
||||
name="Register Collective", contact_reference="CG-R-001",
|
||||
origin_zone=self.zone,
|
||||
)
|
||||
self.client_ops = Client(SERVER_NAME="ops.ixgltd.com")
|
||||
self.client_ops.force_login(self.ops)
|
||||
self.client_buyer = Client(SERVER_NAME="ops.ixgltd.com")
|
||||
self.client_buyer.force_login(self.buyer)
|
||||
|
||||
|
||||
class RegisterAccessTests(ComplianceRegisterBase):
|
||||
def test_ops_can_open_the_register_lists(self):
|
||||
for path in ("/ops/facilities/", "/ops/origin-zones/", "/ops/collector-groups/"):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client_ops.get(path).status_code, 200)
|
||||
|
||||
def test_ops_can_open_the_create_forms(self):
|
||||
for path in (
|
||||
"/ops/facilities/new/",
|
||||
"/ops/origin-zones/new/",
|
||||
"/ops/collector-groups/new/",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client_ops.get(path).status_code, 200)
|
||||
|
||||
def test_a_buyer_cannot_open_the_registers(self):
|
||||
for path in (
|
||||
"/ops/facilities/", "/ops/facilities/new/",
|
||||
f"/ops/facilities/{self.facility.pk}/edit/",
|
||||
"/ops/origin-zones/", "/ops/origin-zones/new/",
|
||||
f"/ops/origin-zones/{self.zone.pk}/edit/",
|
||||
"/ops/collector-groups/", "/ops/collector-groups/new/",
|
||||
f"/ops/collector-groups/{self.group.pk}/edit/",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client_buyer.get(path).status_code, 403)
|
||||
|
||||
def test_the_sidebar_links_to_the_registers(self):
|
||||
html = self.client_ops.get("/ops/facilities/").content.decode()
|
||||
self.assertIn("/ops/facilities/", html)
|
||||
self.assertIn("/ops/origin-zones/", html)
|
||||
self.assertIn("/ops/collector-groups/", html)
|
||||
|
||||
|
||||
class FacilityRegisterTests(ComplianceRegisterBase):
|
||||
def payload(self, **overrides):
|
||||
data = {
|
||||
"name": "New Facility",
|
||||
"country": "Nigeria",
|
||||
"region": "Kaduna State",
|
||||
"address": "",
|
||||
"facility_type": "production",
|
||||
"is_active": "on",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
def test_a_facility_is_created_and_listed(self):
|
||||
r = self.client_ops.post("/ops/facilities/new/", self.payload(), follow=True)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(Facility.objects.filter(name="New Facility").exists())
|
||||
self.assertContains(r, "New Facility")
|
||||
|
||||
def test_a_facility_can_be_edited(self):
|
||||
r = self.client_ops.post(
|
||||
f"/ops/facilities/{self.facility.pk}/edit/",
|
||||
self.payload(name="Renamed Facility"),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.facility.refresh_from_db()
|
||||
self.assertEqual(self.facility.name, "Renamed Facility")
|
||||
|
||||
def test_name_and_country_are_required(self):
|
||||
for field in ("name", "country"):
|
||||
with self.subTest(field=field):
|
||||
r = self.client_ops.post(
|
||||
"/ops/facilities/new/", self.payload(**{field: ""})
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.context["form"].errors.get(field))
|
||||
|
||||
def test_retiring_a_facility_keeps_the_record(self):
|
||||
r = self.client_ops.post(
|
||||
f"/ops/facilities/{self.facility.pk}/edit/",
|
||||
self.payload(name=self.facility.name, is_active=""),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.facility.refresh_from_db()
|
||||
self.assertFalse(self.facility.is_active)
|
||||
self.assertTrue(Facility.objects.filter(pk=self.facility.pk).exists())
|
||||
|
||||
|
||||
class OriginZoneRegisterTests(ComplianceRegisterBase):
|
||||
POLYGON = (
|
||||
'{"type": "Polygon", "coordinates": [[[7.6012, 12.9905], '
|
||||
"[7.618, 12.9905], [7.618, 13.0042], [7.6012, 13.0042], "
|
||||
"[7.6012, 12.9905]]]}"
|
||||
)
|
||||
|
||||
def payload(self, **overrides):
|
||||
data = {
|
||||
"facility": str(self.facility.pk),
|
||||
"zone_code": "NG-KT-RZ02",
|
||||
"country": "Nigeria",
|
||||
"region": "Katsina State",
|
||||
"geometry": self.POLYGON,
|
||||
"coordinate_source": "Field survey",
|
||||
"coordinates_collected_by": "Field Officer",
|
||||
"coordinates_collected_at": "2026-09-01",
|
||||
"evidence_file": "survey-2026-09-01.pdf",
|
||||
"verification_status": "unverified",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
def test_a_zone_is_created_with_its_geometry(self):
|
||||
r = self.client_ops.post("/ops/origin-zones/new/", self.payload(), follow=True)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
zone = OriginZone.objects.get(zone_code="NG-KT-RZ02")
|
||||
self.assertEqual(zone.geometry["type"], "Polygon")
|
||||
self.assertContains(r, "NG-KT-RZ02")
|
||||
|
||||
def test_creating_a_verified_zone_records_who_and_when(self):
|
||||
self.client_ops.post(
|
||||
"/ops/origin-zones/new/", self.payload(verification_status="verified")
|
||||
)
|
||||
zone = OriginZone.objects.get(zone_code="NG-KT-RZ02")
|
||||
self.assertEqual(zone.verified_by, self.ops)
|
||||
self.assertIsNotNone(zone.verified_at)
|
||||
|
||||
def test_moving_a_zone_back_to_unverified_clears_the_stamp(self):
|
||||
self.client_ops.post(
|
||||
f"/ops/origin-zones/{self.zone.pk}/edit/",
|
||||
self.payload(zone_code=self.zone.zone_code, verification_status="unverified"),
|
||||
)
|
||||
self.zone.refresh_from_db()
|
||||
self.assertIsNone(self.zone.verified_by)
|
||||
self.assertIsNone(self.zone.verified_at)
|
||||
|
||||
def test_an_unchanged_verified_zone_keeps_its_verifier(self):
|
||||
other = User.objects.create_user(
|
||||
email="other-verifier@ixg.local", password="pass1234", role="ops",
|
||||
full_name="Other Verifier", is_staff=True,
|
||||
)
|
||||
self.zone.verified_by = other
|
||||
self.zone.save(update_fields=["verified_by"])
|
||||
self.client_ops.post(
|
||||
f"/ops/origin-zones/{self.zone.pk}/edit/",
|
||||
self.payload(
|
||||
zone_code=self.zone.zone_code, verification_status="verified"
|
||||
),
|
||||
)
|
||||
self.zone.refresh_from_db()
|
||||
self.assertEqual(self.zone.verified_by, other)
|
||||
|
||||
def test_invalid_geometry_json_is_refused(self):
|
||||
r = self.client_ops.post(
|
||||
"/ops/origin-zones/new/", self.payload(geometry="{not json")
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.context["form"].errors.get("geometry"))
|
||||
|
||||
def test_a_duplicate_zone_code_is_refused_case_insensitively(self):
|
||||
r = self.client_ops.post(
|
||||
"/ops/origin-zones/new/",
|
||||
self.payload(zone_code=self.zone.zone_code.lower()),
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.context["form"].errors.get("zone_code"))
|
||||
|
||||
def test_the_edit_form_draws_the_stored_polygon(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}/edit/")
|
||||
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())
|
||||
|
||||
def test_a_zone_without_geometry_says_so(self):
|
||||
r = self.client_ops.get(f"/ops/origin-zones/{self.zone.pk}/edit/")
|
||||
html = r.content.decode()
|
||||
self.assertIn("No polygon coordinates are stored", html)
|
||||
self.assertNotIn('id="zone-map"', html)
|
||||
|
||||
|
||||
class CollectorGroupRegisterTests(ComplianceRegisterBase):
|
||||
def payload(self, **overrides):
|
||||
data = {
|
||||
"name": "New Collective",
|
||||
"contact_reference": "CG-NEW-001",
|
||||
"origin_zone": str(self.zone.pk),
|
||||
"notes": "",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
def test_a_group_is_created_and_listed(self):
|
||||
r = self.client_ops.post(
|
||||
"/ops/collector-groups/new/", self.payload(), follow=True
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
group = CollectorGroup.objects.get(name="New Collective")
|
||||
self.assertEqual(group.origin_zone, self.zone)
|
||||
self.assertContains(r, "New Collective")
|
||||
|
||||
def test_a_group_can_be_edited(self):
|
||||
r = self.client_ops.post(
|
||||
f"/ops/collector-groups/{self.group.pk}/edit/",
|
||||
self.payload(name="Renamed Collective"),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.group.refresh_from_db()
|
||||
self.assertEqual(self.group.name, "Renamed Collective")
|
||||
|
||||
def test_the_origin_zone_is_optional(self):
|
||||
self.client_ops.post(
|
||||
"/ops/collector-groups/new/", self.payload(origin_zone="")
|
||||
)
|
||||
group = CollectorGroup.objects.get(name="New Collective")
|
||||
self.assertIsNone(group.origin_zone)
|
||||
|
||||
|
||||
class EUDRCaseChildBase(ComplianceRegisterBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.batch = ProcessingBatch.objects.create(
|
||||
batch_reference="IXG-BATCH-2026-910", harvest_date="2026-09-04",
|
||||
weight_kg=Decimal("500.00"), grade="A", status="harvested",
|
||||
origin_facility=self.facility.name, created_by=self.ops,
|
||||
)
|
||||
self.case = DueDiligenceCase.objects.create(batch=self.batch)
|
||||
|
||||
def case_url(self, path=""):
|
||||
return f"/ops/eudr/{self.case.pk}/{path}"
|
||||
|
||||
def another_case(self):
|
||||
batch = ProcessingBatch.objects.create(
|
||||
batch_reference="IXG-BATCH-2026-911", harvest_date="2026-09-05",
|
||||
weight_kg=Decimal("400.00"), grade="A", status="harvested",
|
||||
origin_facility=self.facility.name, created_by=self.ops,
|
||||
)
|
||||
return DueDiligenceCase.objects.create(batch=batch)
|
||||
|
||||
|
||||
class EUDREvidenceTests(EUDRCaseChildBase):
|
||||
def payload(self, **overrides):
|
||||
data = {
|
||||
"evidence_type": "geolocation",
|
||||
"file": "geolocation.geojson",
|
||||
"source": "Field survey",
|
||||
"collected_at": "2026-09-01",
|
||||
"notes": "",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
def test_evidence_is_attached_and_shown_on_the_case(self):
|
||||
r = self.client_ops.post(self.case_url("evidence/new/"), self.payload(), follow=True)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(self.case.evidence.count(), 1)
|
||||
self.assertContains(r, "geolocation.geojson")
|
||||
|
||||
def test_attaching_evidence_reruns_the_completeness_check(self):
|
||||
self.client_ops.post(
|
||||
self.case_url("evidence/new/"), self.payload(evidence_type="geolocation")
|
||||
)
|
||||
self.case.refresh_from_db()
|
||||
detail = self.case.completeness_results["evidence"]["detail"]
|
||||
self.assertNotIn("geolocation", detail)
|
||||
|
||||
def test_evidence_can_be_edited(self):
|
||||
evidence = EUDREvidence.objects.create(
|
||||
case=self.case, evidence_type="other", file="old.pdf"
|
||||
)
|
||||
r = self.client_ops.post(
|
||||
self.case_url(f"evidence/{evidence.pk}/edit/"), self.payload(file="new.pdf")
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
evidence.refresh_from_db()
|
||||
self.assertEqual(evidence.file, "new.pdf")
|
||||
|
||||
def test_evidence_cannot_be_reached_through_another_case(self):
|
||||
other = self.another_case()
|
||||
evidence = EUDREvidence.objects.create(
|
||||
case=self.case, evidence_type="other", file="x.pdf"
|
||||
)
|
||||
r = self.client_ops.get(f"/ops/eudr/{other.pk}/evidence/{evidence.pk}/edit/")
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_a_buyer_cannot_attach_evidence(self):
|
||||
r = self.client_buyer.post(self.case_url("evidence/new/"), self.payload())
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
|
||||
class MitigationActionTests(EUDRCaseChildBase):
|
||||
def payload(self, **overrides):
|
||||
data = {
|
||||
"action": "Re-survey the origin zone",
|
||||
"status": "open",
|
||||
"owner": "Ops Officer",
|
||||
"due_date": "2026-10-01",
|
||||
"evidence_file": "",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
def test_an_action_is_added_and_shown_on_the_case(self):
|
||||
r = self.client_ops.post(
|
||||
self.case_url("mitigation/new/"), self.payload(), follow=True
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(self.case.mitigation_actions.count(), 1)
|
||||
self.assertContains(r, "Re-survey the origin zone")
|
||||
|
||||
def test_an_action_can_be_edited(self):
|
||||
action = MitigationAction.objects.create(case=self.case, action="Old action")
|
||||
r = self.client_ops.post(
|
||||
self.case_url(f"mitigation/{action.pk}/edit/"),
|
||||
self.payload(action="Corrected action", status="completed"),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
action.refresh_from_db()
|
||||
self.assertEqual(action.action, "Corrected action")
|
||||
self.assertEqual(action.status, MitigationAction.Status.COMPLETED)
|
||||
|
||||
def test_a_buyer_cannot_add_an_action(self):
|
||||
r = self.client_buyer.post(self.case_url("mitigation/new/"), self.payload())
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
|
||||
class StatementReferenceEditTests(EUDRCaseChildBase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.reference = DueDiligenceStatementReference.objects.create(
|
||||
case=self.case, dds_reference="DD-2026-00001",
|
||||
submission_date="2026-09-01", submitted_by=self.ops,
|
||||
)
|
||||
|
||||
def test_a_reference_can_be_reconciled(self):
|
||||
r = self.client_ops.post(
|
||||
self.case_url(f"statement/{self.reference.pk}/edit/"),
|
||||
{
|
||||
"dds_reference": "DD-2026-00001",
|
||||
"submission_date": "2026-09-01",
|
||||
"reconciliation_status": "reconciled",
|
||||
"notes": "",
|
||||
},
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.reference.refresh_from_db()
|
||||
self.assertEqual(
|
||||
self.reference.reconciliation_status,
|
||||
DueDiligenceStatementReference.ReconciliationStatus.RECONCILED,
|
||||
)
|
||||
|
||||
def test_a_reference_is_required(self):
|
||||
r = self.client_ops.post(
|
||||
self.case_url(f"statement/{self.reference.pk}/edit/"),
|
||||
{
|
||||
"dds_reference": "",
|
||||
"submission_date": "2026-09-01",
|
||||
"reconciliation_status": "pending",
|
||||
"notes": "",
|
||||
},
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.context["form"].errors.get("dds_reference"))
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@ urlpatterns = [
|
|||
path('intakes/', views.IntakeListView.as_view(), name='intake_list'),
|
||||
path('intakes/new/', views.IntakeCreateView.as_view(), name='intake_create'),
|
||||
path('intakes/<uuid:pk>/edit/', views.IntakeUpdateView.as_view(), name='intake_update'),
|
||||
path('facilities/', views.FacilityListView.as_view(), name='facility_list'),
|
||||
path('facilities/new/', views.FacilityCreateView.as_view(), name='facility_create'),
|
||||
path('facilities/<uuid:pk>/edit/', views.FacilityUpdateView.as_view(), name='facility_update'),
|
||||
path('origin-zones/', views.OriginZoneListView.as_view(), name='origin_zone_list'),
|
||||
path('origin-zones/new/', views.OriginZoneCreateView.as_view(), name='origin_zone_create'),
|
||||
path('origin-zones/<uuid:pk>/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'),
|
||||
path('collector-groups/<uuid:pk>/edit/', views.CollectorGroupUpdateView.as_view(), name='collector_group_update'),
|
||||
path('qc/', views.QCListView.as_view(), name='qc_list'),
|
||||
path('qc/<uuid:pk>/', views.QCDetailView.as_view(), name='qc_detail'),
|
||||
path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'),
|
||||
|
|
@ -31,6 +40,11 @@ urlpatterns = [
|
|||
path('coa/<uuid:pk>/', views.COAReviewDetailView.as_view(), name='coa_review_detail'),
|
||||
path('eudr/', views.EUDRCaseListView.as_view(), name='eudr_list'),
|
||||
path('eudr/<uuid:pk>/', views.EUDRCaseDetailView.as_view(), name='eudr_detail'),
|
||||
path('eudr/<uuid:case_pk>/evidence/new/', views.EUDREvidenceCreateView.as_view(), name='eudr_evidence_create'),
|
||||
path('eudr/<uuid:case_pk>/evidence/<uuid:pk>/edit/', views.EUDREvidenceUpdateView.as_view(), name='eudr_evidence_update'),
|
||||
path('eudr/<uuid:case_pk>/mitigation/new/', views.MitigationActionCreateView.as_view(), name='eudr_mitigation_create'),
|
||||
path('eudr/<uuid:case_pk>/mitigation/<uuid:pk>/edit/', views.MitigationActionUpdateView.as_view(), name='eudr_mitigation_update'),
|
||||
path('eudr/<uuid:case_pk>/statement/<uuid:pk>/edit/', views.StatementReferenceUpdateView.as_view(), name='eudr_statement_update'),
|
||||
path('help/', views.GuideIndexView.as_view(), name='guide_index'),
|
||||
path('help/<path:page_path>/', views.GuideDetailView.as_view(), name='guide_detail'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from django.views import View
|
|||
from django.shortcuts import get_object_or_404, render
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
from apps.core.tiles import browser_tile_url
|
||||
from apps.qc.models import QCResult
|
||||
from apps.shipments.models import Shipment
|
||||
from apps.capa.models import CAPARecord
|
||||
|
|
@ -21,12 +22,29 @@ from apps.notifications.models import Notification
|
|||
from apps.compliance.models import (
|
||||
CollectorGroup,
|
||||
DueDiligenceCase,
|
||||
DueDiligenceStatementReference,
|
||||
EUDREvidence,
|
||||
Facility,
|
||||
HarvestIntake,
|
||||
IntakeBatchLineage,
|
||||
MitigationAction,
|
||||
OriginZone,
|
||||
)
|
||||
from apps.compliance import services
|
||||
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
||||
from apps.guides.views import GuideIndexView as BaseGuideIndexView
|
||||
from .forms import BatchForm, BatchLineageFormSet, HarvestIntakeForm, ShipmentForm
|
||||
from .forms import (
|
||||
BatchForm,
|
||||
BatchLineageFormSet,
|
||||
CollectorGroupForm,
|
||||
EUDREvidenceForm,
|
||||
FacilityForm,
|
||||
HarvestIntakeForm,
|
||||
MitigationActionForm,
|
||||
OriginZoneForm,
|
||||
ShipmentForm,
|
||||
StatementReferenceForm,
|
||||
)
|
||||
|
||||
|
||||
def suggested_intake_reference(today=None):
|
||||
|
|
@ -777,3 +795,381 @@ class GuideIndexView(OpsRequiredMixin, BaseGuideIndexView):
|
|||
|
||||
class GuideDetailView(OpsRequiredMixin, BaseGuideDetailView):
|
||||
template_name = 'guides/detail_ops.html'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compliance registers
|
||||
#
|
||||
# Facility, origin zone and collector group used to be editable only in the
|
||||
# Django administration area, which left the platform unable to register a new
|
||||
# supply site, zone or group from the portal. These screens close that gap. The
|
||||
# admin registrations stay: the portal and the admin are two write paths to the
|
||||
# same records, and the forms here mirror the admin's fields.
|
||||
|
||||
|
||||
class FacilityListView(OpsRequiredMixin, ListView):
|
||||
model = Facility
|
||||
template_name = 'ops/facility_list.html'
|
||||
context_object_name = 'facilities'
|
||||
paginate_by = 25
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Facility.objects.all().order_by('name')
|
||||
q = self.request.GET.get('q')
|
||||
active = self.request.GET.get('active')
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
if active == '1':
|
||||
qs = qs.filter(is_active=True)
|
||||
elif active == '0':
|
||||
qs = qs.filter(is_active=False)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['current_q'] = self.request.GET.get('q', '')
|
||||
ctx['current_active'] = self.request.GET.get('active', '')
|
||||
return ctx
|
||||
|
||||
|
||||
class FacilityFormContextMixin:
|
||||
model = Facility
|
||||
form_class = FacilityForm
|
||||
template_name = 'ops/register_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['cancel_url'] = reverse('ops:facility_list')
|
||||
return ctx
|
||||
|
||||
|
||||
class FacilityCreateView(FacilityFormContextMixin, OpsRequiredMixin, CreateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New facility'
|
||||
ctx['submit_label'] = 'Create facility'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Facility {self.object.name} created.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:facility_list')
|
||||
|
||||
|
||||
class FacilityUpdateView(FacilityFormContextMixin, OpsRequiredMixin, UpdateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = f"Edit facility {self.object.name}"
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Facility {self.object.name} updated.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:facility_list')
|
||||
|
||||
|
||||
def apply_zone_verification(zone, user, previous_status=None):
|
||||
"""Record who verified or rejected a zone, and when.
|
||||
|
||||
A zone moved back to Unverified has its stamp cleared, so the record and the
|
||||
audit agree. An unchanged status keeps the original verifier and date — a
|
||||
later edit of an unrelated field must not silently re-attribute the check.
|
||||
"""
|
||||
if zone.verification_status == OriginZone.VerificationStatus.UNVERIFIED:
|
||||
zone.verified_by = None
|
||||
zone.verified_at = None
|
||||
elif previous_status != zone.verification_status or zone.verified_at is None:
|
||||
zone.verified_by = user
|
||||
zone.verified_at = timezone.now()
|
||||
|
||||
|
||||
class OriginZoneListView(OpsRequiredMixin, ListView):
|
||||
model = OriginZone
|
||||
template_name = 'ops/origin_zone_list.html'
|
||||
context_object_name = 'zones'
|
||||
paginate_by = 25
|
||||
|
||||
def get_queryset(self):
|
||||
qs = OriginZone.objects.select_related('facility').order_by('zone_code')
|
||||
q = self.request.GET.get('q')
|
||||
status = self.request.GET.get('status')
|
||||
if q:
|
||||
qs = qs.filter(zone_code__icontains=q)
|
||||
if status:
|
||||
qs = qs.filter(verification_status=status)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['status_choices'] = OriginZone.VerificationStatus.choices
|
||||
ctx['current_q'] = self.request.GET.get('q', '')
|
||||
ctx['current_status'] = self.request.GET.get('status', '')
|
||||
return ctx
|
||||
|
||||
|
||||
class OriginZoneFormContextMixin:
|
||||
model = OriginZone
|
||||
form_class = OriginZoneForm
|
||||
template_name = 'ops/register_form.html'
|
||||
|
||||
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
|
||||
|
||||
def form_valid(self, form):
|
||||
apply_zone_verification(
|
||||
form.instance,
|
||||
self.request.user,
|
||||
previous_status=form.initial.get('verification_status'),
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class OriginZoneCreateView(OriginZoneFormContextMixin, OpsRequiredMixin, CreateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New origin zone'
|
||||
ctx['submit_label'] = 'Create zone'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
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.",
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:origin_zone_update', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class OriginZoneUpdateView(OriginZoneFormContextMixin, OpsRequiredMixin, UpdateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = f"Edit origin zone {self.object.zone_code}"
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request, f"Origin zone {self.object.zone_code} updated."
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:origin_zone_update', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class CollectorGroupListView(OpsRequiredMixin, ListView):
|
||||
model = CollectorGroup
|
||||
template_name = 'ops/collector_group_list.html'
|
||||
context_object_name = 'groups'
|
||||
paginate_by = 25
|
||||
|
||||
def get_queryset(self):
|
||||
qs = CollectorGroup.objects.select_related('origin_zone').order_by('name')
|
||||
q = self.request.GET.get('q')
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['current_q'] = self.request.GET.get('q', '')
|
||||
return ctx
|
||||
|
||||
|
||||
class CollectorGroupFormContextMixin:
|
||||
model = CollectorGroup
|
||||
form_class = CollectorGroupForm
|
||||
template_name = 'ops/register_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['cancel_url'] = reverse('ops:collector_group_list')
|
||||
return ctx
|
||||
|
||||
|
||||
class CollectorGroupCreateView(CollectorGroupFormContextMixin, OpsRequiredMixin, CreateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New collector group'
|
||||
ctx['submit_label'] = 'Create group'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Collector group {self.object.name} created.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:collector_group_list')
|
||||
|
||||
|
||||
class CollectorGroupUpdateView(CollectorGroupFormContextMixin, OpsRequiredMixin, UpdateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = f"Edit collector group {self.object.name}"
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Collector group {self.object.name} updated.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:collector_group_list')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Records that belong to a due-diligence case
|
||||
#
|
||||
# Mitigation actions, EUDR evidence and external-submission references used to
|
||||
# be creatable/editable only in admin (or, for evidence, not at all from the
|
||||
# portal). These screens are reached from the case detail and return to it.
|
||||
|
||||
|
||||
class CaseChildMixin(OpsRequiredMixin):
|
||||
template_name = 'ops/register_form.html'
|
||||
|
||||
def get_case(self):
|
||||
return get_object_or_404(DueDiligenceCase, pk=self.kwargs['case_pk'])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['case'] = self.get_case()
|
||||
ctx['cancel_url'] = reverse(
|
||||
'ops:eudr_detail', kwargs={'pk': self.kwargs['case_pk']}
|
||||
)
|
||||
return ctx
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:eudr_detail', kwargs={'pk': self.kwargs['case_pk']})
|
||||
|
||||
|
||||
class EUDREvidenceCreateView(CaseChildMixin, CreateView):
|
||||
model = EUDREvidence
|
||||
form_class = EUDREvidenceForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Attach evidence — {self.get_case().batch.batch_reference}"
|
||||
)
|
||||
ctx['submit_label'] = 'Attach evidence'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.case = self.get_case()
|
||||
response = super().form_valid(form)
|
||||
# Adding evidence changes the evidence completeness result, so re-run it
|
||||
# exactly as the API does.
|
||||
services.run_completeness_check(self.object.case)
|
||||
messages.success(
|
||||
self.request,
|
||||
"Evidence attached. The case's completeness check has been re-run.",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class EUDREvidenceUpdateView(CaseChildMixin, UpdateView):
|
||||
model = EUDREvidence
|
||||
form_class = EUDREvidenceForm
|
||||
|
||||
def get_queryset(self):
|
||||
return EUDREvidence.objects.filter(case_id=self.kwargs['case_pk'])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Edit evidence — {self.get_case().batch.batch_reference}"
|
||||
)
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
services.run_completeness_check(self.object.case)
|
||||
messages.success(self.request, 'Evidence updated.')
|
||||
return response
|
||||
|
||||
|
||||
class MitigationActionCreateView(CaseChildMixin, CreateView):
|
||||
model = MitigationAction
|
||||
form_class = MitigationActionForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Add mitigation action — {self.get_case().batch.batch_reference}"
|
||||
)
|
||||
ctx['submit_label'] = 'Add action'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.case = self.get_case()
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, 'Mitigation action added.')
|
||||
return response
|
||||
|
||||
|
||||
class MitigationActionUpdateView(CaseChildMixin, UpdateView):
|
||||
model = MitigationAction
|
||||
form_class = MitigationActionForm
|
||||
|
||||
def get_queryset(self):
|
||||
return MitigationAction.objects.filter(case_id=self.kwargs['case_pk'])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Edit mitigation action — {self.get_case().batch.batch_reference}"
|
||||
)
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, 'Mitigation action updated.')
|
||||
return response
|
||||
|
||||
|
||||
class StatementReferenceUpdateView(CaseChildMixin, UpdateView):
|
||||
model = DueDiligenceStatementReference
|
||||
form_class = StatementReferenceForm
|
||||
|
||||
def get_queryset(self):
|
||||
return DueDiligenceStatementReference.objects.filter(
|
||||
case_id=self.kwargs['case_pk']
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Edit submission reference — {self.get_case().batch.batch_reference}"
|
||||
)
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, 'Submission reference updated.')
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ body { font-size: .875rem; background: #f5f6fa; }
|
|||
.guide-content pre { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: .375rem; padding: .75rem; }
|
||||
.guide-content blockquote { border-left: 4px solid #a7f3d0; padding-left: 1rem; color: #475569; }
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -37,6 +38,9 @@ body { font-size: .875rem; background: #f5f6fa; }
|
|||
<ul class="nav flex-column">
|
||||
<li class="nav-item"><a class="nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}" href="{% url 'ops:dashboard' %}"><i class="bi bi-speedometer2"></i> Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'intake' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:intake_list' %}"><i class="bi bi-basket"></i> Harvest Intakes</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'facility' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:facility_list' %}"><i class="bi bi-building"></i> Facilities</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'origin_zone' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:origin_zone_list' %}"><i class="bi bi-geo-alt"></i> Origin Zones</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'collector' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:collector_group_list' %}"><i class="bi bi-people"></i> Collector Groups</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'batch' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:batch_list' %}"><i class="bi bi-box-seam"></i> Batches</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>
|
||||
|
|
@ -68,5 +72,6 @@ body { font-size: .875rem; background: #f5f6fa; }
|
|||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
79
templates/ops/collector_group_list.html
Normal file
79
templates/ops/collector_group_list.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Collector Groups - IXG Operations{% 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">
|
||||
<h2>Collector Groups</h2>
|
||||
<div>
|
||||
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
|
||||
<a href="{% url 'ops:collector_group_create' %}" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> New collector group</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search name..." value="{{ current_q }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
<a href="{% url 'ops:collector_group_list' %}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="stat-card p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Contact reference</th>
|
||||
<th>Origin zone</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for g in groups %}
|
||||
<tr>
|
||||
<td><strong>{{ g.name }}</strong></td>
|
||||
<td>{{ g.contact_reference|default:'-' }}</td>
|
||||
<td>
|
||||
{% if g.origin_zone %}
|
||||
{{ g.origin_zone.zone_code }}
|
||||
{% if g.origin_zone.verification_status != 'verified' %}
|
||||
<span class="badge bg-warning text-dark">{{ g.origin_zone.get_verification_status_display }}</span>
|
||||
{% endif %}
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
<td class="text-truncate" style="max-width:280px;">{{ g.notes|default:'-' }}</td>
|
||||
<td><a href="{% url 'ops:collector_group_update' g.pk %}" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="text-muted text-center py-3">No collector groups registered yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if page_obj.paginator.num_pages > 1 %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination pagination-sm justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page=1">«</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">{{ page_obj.previous_page_number }}</a></li>
|
||||
{% endif %}
|
||||
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">{{ page_obj.next_page_number }}</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -58,6 +58,22 @@
|
|||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="stat-card mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">EUDR Evidence</h6>
|
||||
<a href="{% url 'ops:eudr_evidence_create' case.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-paperclip"></i> Attach evidence</a>
|
||||
</div>
|
||||
{% for e in evidence %}
|
||||
<div class="border-bottom pb-2 mb-2">
|
||||
<span class="badge bg-secondary">{{ e.get_evidence_type_display }}</span>
|
||||
<span class="small">{{ e.file }} <span class="text-muted">{% if e.source %}· {{ e.source }}{% endif %}{% if e.collected_at %} · {{ e.collected_at|date:'d/m/Y' }}{% endif %}</span></span>
|
||||
<a href="{% url 'ops:eudr_evidence_update' case.pk e.pk %}" class="btn btn-sm btn-link p-0 ms-2">Edit</a>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted mb-0">No evidence attached. The completeness check needs geolocation, harvest document and supplier identity.</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3">Risk Assessments</h6>
|
||||
{% for r in risk_assessments %}
|
||||
|
|
@ -72,11 +88,15 @@
|
|||
</div>
|
||||
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3">Mitigation Actions</h6>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Mitigation Actions</h6>
|
||||
<a href="{% url 'ops:eudr_mitigation_create' case.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-plus-lg"></i> Add action</a>
|
||||
</div>
|
||||
{% for m in mitigation_actions %}
|
||||
<div class="border-bottom pb-2 mb-2">
|
||||
<span class="badge {% if m.status == 'verified' %}bg-success{% elif m.status == 'completed' %}bg-primary{% else %}bg-warning text-dark{% endif %}">{{ m.get_status_display }}</span>
|
||||
<span class="small">{{ m.action }} <span class="text-muted">(owner: {{ m.owner|default:'-' }}{% if m.due_date %}, due {{ m.due_date|date:'d/m/Y' }}{% endif %})</span></span>
|
||||
<a href="{% url 'ops:eudr_mitigation_update' case.pk m.pk %}" class="btn btn-sm btn-link p-0 ms-2">Edit</a>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted mb-0">No mitigation actions.</p>
|
||||
|
|
@ -88,7 +108,8 @@
|
|||
{% for s in statement_references %}
|
||||
<div class="border-bottom pb-2 mb-2">
|
||||
<span class="badge bg-secondary">{{ s.reconciliation_status }}</span>
|
||||
<span class="small">Ref: <code>{{ s.dds_reference }}</code> · {{ s.submission_date|date:'d/m/Y'|default:'-' }} · by {{ s.submitted_by.full_name|default:'-' }}</span>
|
||||
<span class="small">Ref: <code>{{ s.dds_reference }}</code> · {{ s.submission_date|date:'d/m/Y'|default:'-' }} · by {{ s.submitted_by.full_name|default:'-' }}</span>
|
||||
<a href="{% url 'ops:eudr_statement_update' case.pk s.pk %}" class="btn btn-sm btn-link p-0 ms-2">Edit</a>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted mb-0">No external submission recorded. Submissions are manual.</p>
|
||||
|
|
|
|||
81
templates/ops/facility_list.html
Normal file
81
templates/ops/facility_list.html
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Facilities - IXG Operations{% 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">
|
||||
<h2>Facilities</h2>
|
||||
<div>
|
||||
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
|
||||
<a href="{% url 'ops:facility_create' %}" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> New facility</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search name..." value="{{ current_q }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="active" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
<option value="1" {% if current_active == '1' %}selected{% endif %}>Active</option>
|
||||
<option value="0" {% if current_active == '0' %}selected{% endif %}>Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
<a href="{% url 'ops:facility_list' %}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="stat-card p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Country</th>
|
||||
<th>Region</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in facilities %}
|
||||
<tr>
|
||||
<td><strong>{{ f.name }}</strong></td>
|
||||
<td>{{ f.country }}</td>
|
||||
<td>{{ f.region|default:'-' }}</td>
|
||||
<td>{{ f.get_facility_type_display }}</td>
|
||||
<td>{% if f.is_active %}<span class="badge bg-success">Active</span>{% else %}<span class="badge bg-secondary">Retired</span>{% endif %}</td>
|
||||
<td><a href="{% url 'ops:facility_update' f.pk %}" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-muted text-center py-3">No facilities registered yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if page_obj.paginator.num_pages > 1 %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination pagination-sm justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page=1">«</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">{{ page_obj.previous_page_number }}</a></li>
|
||||
{% endif %}
|
||||
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">{{ page_obj.next_page_number }}</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
90
templates/ops/origin_zone_list.html
Normal file
90
templates/ops/origin_zone_list.html
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Origin Zones - IXG Operations{% 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">
|
||||
<h2>Origin Zones</h2>
|
||||
<div>
|
||||
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
|
||||
<a href="{% url 'ops:origin_zone_create' %}" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> New origin zone</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search zone code..." value="{{ current_q }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="">All Statuses</option>
|
||||
{% for val, label in status_choices %}
|
||||
<option value="{{ val }}" {% if current_status == val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
<a href="{% url 'ops:origin_zone_list' %}" class="btn btn-sm btn-outline-secondary">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="stat-card p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zone code</th>
|
||||
<th>Facility</th>
|
||||
<th>Country</th>
|
||||
<th>Region</th>
|
||||
<th>Verification</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for z in zones %}
|
||||
<tr>
|
||||
<td><strong>{{ z.zone_code }}</strong></td>
|
||||
<td>{{ z.facility.name|default:'-' }}</td>
|
||||
<td>{{ z.country }}</td>
|
||||
<td>{{ z.region|default:'-' }}</td>
|
||||
<td>
|
||||
{% if z.verification_status == 'verified' %}
|
||||
<span class="badge bg-success">{{ z.get_verification_status_display }}</span>
|
||||
{% elif z.verification_status == 'rejected' %}
|
||||
<span class="badge bg-danger">{{ z.get_verification_status_display }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">{{ z.get_verification_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a href="{% url 'ops:origin_zone_update' z.pk %}" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-muted text-center py-3">No origin zones registered yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if page_obj.paginator.num_pages > 1 %}
|
||||
<nav class="mt-3">
|
||||
<ul class="pagination pagination-sm justify-content-center">
|
||||
{% if page_obj.has_previous %}
|
||||
<li class="page-item"><a class="page-link" href="?page=1">«</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">{{ page_obj.previous_page_number }}</a></li>
|
||||
{% endif %}
|
||||
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">{{ page_obj.next_page_number }}</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
95
templates/ops/register_form.html
Normal file
95
templates/ops/register_form.html
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% load static %}
|
||||
{% block title %}{{ page_title }} - IXG Operations{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if show_zone_map %}
|
||||
<link rel="stylesheet" href="{% static 'vendor/leaflet/leaflet.css' %}">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>{{ page_title }}</h2>
|
||||
<a href="{{ cancel_url }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7">
|
||||
<div class="stat-card">
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
{% for e in form.non_field_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="mb-3">
|
||||
{% if field.field.widget.input_type == 'checkbox' %}
|
||||
<div class="form-check">
|
||||
{{ field }}
|
||||
<label class="form-check-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
</div>
|
||||
{% else %}
|
||||
<label class="form-label" for="{{ field.id_for_label }}">
|
||||
{{ field.label }}{% if field.field.required %} <span class="text-danger">*</span>{% endif %}
|
||||
</label>
|
||||
{{ field }}
|
||||
{% endif %}
|
||||
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
|
||||
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit" class="btn btn-primary">{{ submit_label }}</button>
|
||||
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
{% if case %}
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3"><i class="bi bi-shield-check"></i> Due-diligence case</h6>
|
||||
<p class="small mb-1">Batch <strong>{{ case.batch.batch_reference }}</strong></p>
|
||||
<p class="small mb-0 text-secondary">Status: {{ case.get_status_display }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% 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:340px;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 %}
|
||||
<p class="small text-secondary mb-0">
|
||||
No polygon coordinates are stored for this zone yet, so there is nothing
|
||||
to draw. 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.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</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 %}
|
||||
Loading…
Add table
Reference in a new issue