ixg_platform/apps/ops/forms.py
ixgadmin df2a90bc71 Add the product specification screen to the ops portal
Operations can now create and edit a specification and its parameters
together, over the existing model, mirroring the admin's inline.

The approval half is deliberately held: the form does not expose status,
is_current, approved_by or approved_at, so a portal-created specification
is a draft and cannot be approved here. The edit screen warns when the
record is not a draft, because its parameters are the limits QC results
are judged against. Nothing is removed from the admin.

The parameter formset refuses an empty specification (with no parameters
every QC result would pass), a numeric parameter with no limits, and a
visual parameter with no allowed values.
2026-09-25 16:35:45 +00:00

1136 lines
47 KiB
Python

"""Forms for the ops portal.
Both forms here share a principle: **stricter than the model.**
The intake form is stricter about *completeness*. ``HarvestIntake`` allows almost
everything to be blank, because records also arrive through the API, and the
platform copes by flagging the gaps later: the EUDR completeness check has an
"intake data" test whose whole job is to catch a missing date or weight. A portal
form that let the same blanks through would simply move that problem to a screen
where a person could have prevented it. So every field the checks read is
required — reference, collector group, origin zone, date, weight and grade — and
the fields that are genuinely optional at intake stay optional.
The batch form and the lineage formset are stricter about *contradiction*. A
batch's status is not a free field: recording a QC result sets it to QC Pass or QC
Fail, so the form refuses to let a person type over a quality failure. And an
intake's weight is finite, so the formset refuses to allocate more of it than was
received, counting every batch that has already drawn from it.
Both reasons are stated in the docstrings of the classes themselves, because a
maintainer's instinct is otherwise to "fix" them back to match the model.
"""
from django import forms
from django.db.models import Count, Q, Sum
from django.forms import inlineformset_factory, modelformset_factory
from apps.batches.models import ProcessingBatch
from apps.compliance import services
from apps.compliance.models import (
CollectorGroup,
DueDiligenceStatementReference,
EUDREvidence,
Facility,
HarvestIntake,
IntakeBatchLineage,
MitigationAction,
OriginZone,
ProductSpecification,
SpecParameter,
)
from apps.shipments.models import Shipment
# The visual grades the product specification defines.
GRADE_CHOICES = [("A", "Grade A"), ("B", "Grade B"), ("C", "Grade C")]
class ZoneChoiceField(forms.ModelChoiceField):
"""Zone dropdown that says which zones are not verified yet.
Geolocation in the EUDR case passes only for a verified zone, so an operator
choosing one should be able to see that before it fails a check. It is a
label, not a block: an intake can legitimately be recorded against a zone
that is still awaiting verification.
"""
def label_from_instance(self, zone):
where = zone.region or zone.country or ""
mark = ""
if zone.verification_status != OriginZone.VerificationStatus.VERIFIED:
mark = f" — {zone.get_verification_status_display().lower()}"
return f"{zone.zone_code}{f' ({where})' if where else ''}{mark}"
class CollectorGroupChoiceField(forms.ModelChoiceField):
"""Group dropdown that names the zone each group draws from."""
def label_from_instance(self, group):
if not group.origin_zone:
return f"{group.name} — no origin zone set"
return f"{group.name} — {group.origin_zone.zone_code}"
class HarvestIntakeForm(forms.ModelForm):
collector_group = CollectorGroupChoiceField(
queryset=CollectorGroup.objects.select_related("origin_zone").order_by("name"),
label="Collector group",
help_text="The registered group that delivered the material.",
empty_label="Select the collector group",
)
origin_zone = ZoneChoiceField(
queryset=OriginZone.objects.order_by("zone_code"),
label="Origin zone",
help_text="Where it was collected. Filled in from the group, and can be changed.",
empty_label="Select the origin zone",
)
grade = forms.ChoiceField(
choices=GRADE_CHOICES,
label="Grade",
help_text="Visual grade recorded at intake, as defined by the product specification.",
widget=forms.Select(attrs={"class": "form-select"}),
)
class Meta:
model = HarvestIntake
fields = [
"intake_reference",
"collector_group",
"origin_zone",
"intake_date",
"weight_kg",
"grade",
"receiving_officer",
"status",
"evidence_file",
]
labels = {
"intake_reference": "Intake reference",
"intake_date": "Intake date",
"weight_kg": "Weight (kg)",
"receiving_officer": "Receiving officer",
"evidence_file": "Supporting file name",
}
help_texts = {
"intake_reference": "Must be unique. Suggested from the last intake recorded, and can be overwritten to match the weighbridge ticket.",
"intake_date": "The day the material was received.",
"weight_kg": "Weight received, in kilograms.",
"receiving_officer": "Who received and weighed it. Defaults to you.",
"status": "Received is the normal state for a new intake; Verified is set once the record has been checked.",
"evidence_file": "The name of the supporting record, typically the weighbridge ticket. Files are not uploaded by the platform yet, so this is the name it is filed under.",
}
widgets = {
"intake_reference": forms.TextInput(
attrs={"class": "form-control", "autocomplete": "off"}
),
"collector_group": forms.Select(attrs={"class": "form-select"}),
"origin_zone": forms.Select(attrs={"class": "form-select"}),
# A native date input only accepts the ISO format. Without this the
# en-gb locale renders "04/09/2026", which the browser silently
# discards, leaving the field empty on an edit.
"intake_date": forms.DateInput(
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
),
"weight_kg": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01", "min": "0.01"}
),
"receiving_officer": forms.TextInput(attrs={"class": "form-control"}),
"status": forms.Select(attrs={"class": "form-select"}),
"evidence_file": forms.TextInput(attrs={"class": "form-control"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# The model tolerates blanks; the portal form does not. See the module
# docstring: these are the fields the completeness checks read.
for name in ("collector_group", "origin_zone", "intake_date", "weight_kg"):
self.fields[name].required = True
self.fields["status"].required = True
def clean_intake_reference(self):
reference = (self.cleaned_data["intake_reference"] or "").strip()
if not reference:
raise forms.ValidationError("An intake reference is required.")
clashes = HarvestIntake.objects.filter(intake_reference__iexact=reference)
if self.instance.pk:
clashes = clashes.exclude(pk=self.instance.pk)
if clashes.exists():
raise forms.ValidationError(
f"Intake {reference} already exists. References must be unique."
)
return reference
def clean_weight_kg(self):
weight = self.cleaned_data["weight_kg"]
if weight is None:
raise forms.ValidationError("A weight is required.")
if weight <= 0:
raise forms.ValidationError("Weight must be greater than zero.")
return weight
def clean(self):
cleaned = super().clean()
group = cleaned.get("collector_group")
zone = cleaned.get("origin_zone")
if group and zone and group.origin_zone_id and group.origin_zone_id != zone.pk:
# Not an error — a group can deliver from more than one area — but it
# is worth surfacing, because the two records will disagree.
self.add_error(
"origin_zone",
forms.ValidationError(
f"{group.name} is registered to {group.origin_zone.zone_code}, "
f"not {zone.zone_code}. Save it this way only if that is intended."
),
)
return cleaned
# ---------------------------------------------------------------------------
# Batches
#
# The batch form exists for a different reason from the intake form: not to stop
# incomplete records, but to stop **contradictory** ones. A batch's status is not
# purely a human field — recording a QC result sets it to QC Pass or QC Fail — so
# a free status dropdown would let someone quietly type over a quality failure.
# The rules below are the whole point of the screen; read them before changing
# them.
# The statuses the platform sets from a QC result, and which a person therefore
# must not be able to type.
QC_SET_STATUSES = [
ProcessingBatch.Status.QC_PASS,
ProcessingBatch.Status.QC_FAIL,
]
# ...and of those, the one that is sticky: a failed batch cannot be moved out of
# its failure by hand at all, only by a new result. QC Pass is not sticky, because
# a passed batch is exactly the batch that goes to sea.
STICKY_STATUSES = [ProcessingBatch.Status.QC_FAIL]
# The journey statuses, which a person does move by hand. The real world is
# untidy: a batch can genuinely go back to processing.
JOURNEY_STATUSES = [
ProcessingBatch.Status.HARVESTED,
ProcessingBatch.Status.PROCESSING,
ProcessingBatch.Status.READY_TO_SHIP,
ProcessingBatch.Status.IN_TRANSIT,
ProcessingBatch.Status.AT_UK_PORT,
ProcessingBatch.Status.CUSTOMS,
ProcessingBatch.Status.DELIVERED,
]
# A batch cannot be born mid-journey: it exists because material was processed.
OPENING_STATUSES = [ProcessingBatch.Status.HARVESTED, ProcessingBatch.Status.PROCESSING]
class BatchForm(forms.ModelForm):
"""Create or correct a processing batch.
Two rules worth stating plainly, because they are the difference between a
working screen and a dangerous one:
- **QC Fail is sticky.** A batch sitting on a failed QC result cannot be
moved by hand at all. Only a new result — a retest — takes it off. To let
the portal move it would be to give the screen a way to make a quality
failure disappear.
- **QC Pass can move on.** It is offered as the current value (so saving
alone changes nothing) alongside the journey statuses, because a batch that
has passed QC is exactly the batch that goes to sea.
"""
status = forms.ChoiceField(
choices=[],
label="Status",
help_text="Where the batch is in its journey.",
widget=forms.Select(attrs={"class": "form-select"}),
)
# Declared here so the required field opens on a plain instruction rather than
# Django's bare "---------".
grade = forms.ChoiceField(
choices=[("", "Select the grade")] + GRADE_CHOICES,
label="Grade",
help_text="The visual grade recorded for the batch, as defined by the product specification.",
widget=forms.Select(attrs={"class": "form-select"}),
)
class Meta:
model = ProcessingBatch
fields = [
"batch_reference",
"grade",
"weight_kg",
"harvest_date",
"origin_facility",
"notes",
"status",
]
labels = {
"batch_reference": "Batch reference",
"weight_kg": "Weight (kg)",
"harvest_date": "Harvest date",
"origin_facility": "Origin facility",
"notes": "Notes",
}
help_texts = {
"batch_reference": "Must be unique. Suggested from the last batch recorded, and can be overwritten.",
"weight_kg": "The batch weight as processed, in kilograms.",
"harvest_date": "The date the underlying harvest was taken.",
"origin_facility": "The processing site. New batches choose from the facility register; an existing batch recorded against a site that is no longer registered keeps its own value, flagged so the drift is visible.",
"notes": "Anything the next person needs to know about this batch.",
}
widgets = {
"batch_reference": forms.TextInput(
attrs={"class": "form-control", "autocomplete": "off"}
),
"grade": forms.Select(attrs={"class": "form-select"}),
"weight_kg": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01", "min": "0.01"}
),
"harvest_date": forms.DateInput(
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
),
"notes": forms.Textarea(attrs={"class": "form-control", "rows": 4}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# `_state.adding`, not `pk is None`: ProcessingBatch's primary key is a
# UUIDField with a default, so an unsaved instance already has a pk, and
# `bool(instance.pk)` reads as "editing" for a brand-new batch.
is_new = self.instance._state.adding
locked = not is_new and self.instance.status in STICKY_STATUSES
if locked:
# Shown, not editable. `disabled` means Django validates the initial
# value, so the current status is the only choice on offer.
self.fields["status"].choices = [
(self.instance.status, self.instance.get_status_display())
]
self.fields["status"].disabled = True
self.fields["status"].help_text = (
"Set by the QC result and cannot be changed here. A new result — "
"a retest — is what moves it."
)
elif not is_new:
choices = list(JOURNEY_STATUSES)
if self.instance.status == ProcessingBatch.Status.QC_PASS:
# Offered so that saving alone leaves it alone, and so it can be
# moved on to the journey statuses.
choices.insert(0, ProcessingBatch.Status.QC_PASS)
self.fields["status"].choices = [(s.value, s.label) for s in choices]
else:
self.fields["status"].choices = [
(s.value, s.label) for s in OPENING_STATUSES
]
self.fields["status"].initial = ProcessingBatch.Status.HARVESTED
# The origin facility comes from the register. An existing batch whose
# recorded name is not in the register keeps its own value as a choice —
# history is not silently rewritten — but it is labelled as the odd one
# out so the drift is visible.
current = "" if is_new else (self.instance.origin_facility or "").strip()
registered = [
f.name for f in Facility.objects.filter(is_active=True).order_by("name")
]
choices = [(name, name) for name in registered]
if current and current not in registered:
choices.append((current, f"{current} — not in the facility register"))
self.fields["origin_facility"] = forms.ChoiceField(
choices=choices,
required=True,
label="Origin facility",
help_text=self.Meta.help_texts["origin_facility"],
widget=forms.Select(attrs={"class": "form-select"}),
)
self.fields["origin_facility"].initial = current or (
registered[0] if len(registered) == 1 else ""
)
for name in ("grade", "weight_kg", "harvest_date"):
self.fields[name].required = True
def clean_batch_reference(self):
reference = (self.cleaned_data["batch_reference"] or "").strip()
if not reference:
raise forms.ValidationError("A batch reference is required.")
clashes = ProcessingBatch.objects.filter(batch_reference__iexact=reference)
if not self.instance._state.adding:
clashes = clashes.exclude(pk=self.instance.pk)
if clashes.exists():
raise forms.ValidationError(
f"Batch {reference} already exists. References must be unique."
)
return reference
def clean_weight_kg(self):
weight = self.cleaned_data["weight_kg"]
if weight is None:
raise forms.ValidationError("A weight is required.")
if weight <= 0:
raise forms.ValidationError("Weight must be greater than zero.")
return weight
def clean_status(self):
status = self.cleaned_data.get("status")
# A locked batch's own status arrives with the form and must be allowed
# through — it is only a *different* QC status that is a hand-setting.
unchanged = not self.instance._state.adding and status == self.instance.status
if status in QC_SET_STATUSES and not unchanged:
raise forms.ValidationError(
f"Batch status {ProcessingBatch.Status(status).label} is set by the "
"QC result, not by hand."
)
if self.instance._state.adding and status not in OPENING_STATUSES:
raise forms.ValidationError(
"A new batch starts at Harvested or Processing."
)
return status
class IntakeChoiceField(forms.ModelChoiceField):
"""Intake dropdown that carries the facts needed to allocate from it.
An operator deciding how much of an intake can go into a batch needs the
weight received and what is left of it. Both are read off annotations added by
the view, so this costs no extra queries.
Kept short on purpose: a native ``<select>`` sizes itself to its longest
option, so a long label — the collector group's full name, say — widens the
column past the card and pushes the allocated-weight field out of sight.
"""
def label_from_instance(self, intake):
parts = [intake.intake_reference]
if intake.weight_kg is not None:
parts.append(f"{intake.weight_kg} kg received")
elsewhere = getattr(intake, "allocated_elsewhere", None)
if elsewhere and intake.weight_kg is not None:
left = intake.weight_kg - elsewhere
parts.append(f"{left} kg left" if left > 0 else "fully allocated")
return " — ".join(parts)
class BatchLineageForm(forms.ModelForm):
"""One intake→batch allocation."""
harvest_intake = IntakeChoiceField(
queryset=HarvestIntake.objects.none(),
label="Harvest intake",
empty_label="Select the harvest intake",
)
class Meta:
model = IntakeBatchLineage
fields = ["harvest_intake", "weight_allocated_kg"]
labels = {"weight_allocated_kg": "Allocated weight (kg)"}
help_texts = {
"weight_allocated_kg": "How much of this intake went into this batch."
}
widgets = {
"weight_allocated_kg": forms.NumberInput(
attrs={"class": "form-control", "step": "0.01", "min": "0.01"}
),
}
def __init__(self, *args, batch=None, **kwargs):
super().__init__(*args, **kwargs)
self.batch = batch
queryset = HarvestIntake.objects.select_related(
"collector_group", "origin_zone"
).order_by("intake_reference")
if batch is not None:
# What every *other* batch has taken from this intake, so the
# dropdown can say so and the formset can refuse the over-allocation.
queryset = queryset.annotate(
allocated_elsewhere=Sum(
"batch_lineage__weight_allocated_kg",
filter=~Q(batch_lineage__batch=batch),
)
)
self.fields["harvest_intake"].queryset = queryset
self.fields["weight_allocated_kg"].required = True
def clean_weight_allocated_kg(self):
weight = self.cleaned_data["weight_allocated_kg"]
if weight is None:
raise forms.ValidationError("An allocated weight is required.")
if weight <= 0:
raise forms.ValidationError("Allocated weight must be greater than zero.")
return weight
class BaseBatchLineageFormSet(forms.BaseModelFormSet):
"""Refuses allocations that the intake cannot support.
The model has no such rule — ``weight_allocated_kg`` is a bare decimal — which
is how the seeded data ended up allocating 800 kg of an 820 kg intake into two
different batches. The rule enforced here counts **every** batch, not just
this one, because the intake is a finite quantity of shea and the whole point
of lineage is that it adds up.
"""
def __init__(self, *args, batch=None, **kwargs):
self.batch = batch
super().__init__(*args, **kwargs)
def clean(self):
super().clean()
if any(self.errors):
# Per-form errors already say what is wrong; piling cross-row errors
# on top of them makes the screen unreadable.
return
rows = [] # (form, intake, weight)
seen = {}
for form in self.forms:
if not getattr(form, "cleaned_data", None):
continue
if form.cleaned_data.get("DELETE"):
continue
intake = form.cleaned_data.get("harvest_intake")
weight = form.cleaned_data.get("weight_allocated_kg")
if not intake or weight is None:
continue
if intake.pk in seen:
form.add_error(
"harvest_intake",
forms.ValidationError(
f"{intake.intake_reference} is already linked to this batch. "
"Each intake appears once — add the two allocations together."
),
)
continue
seen[intake.pk] = True
rows.append((form, intake, weight))
for form, intake, weight in rows:
message = services.intake_allocation_error(
intake, weight, exclude_batch=self.batch
)
if message:
form.add_error(
"weight_allocated_kg", forms.ValidationError(message)
)
BatchLineageFormSet = modelformset_factory(
IntakeBatchLineage,
form=BatchLineageForm,
formset=BaseBatchLineageFormSet,
extra=1,
can_delete=True,
)
# ---------------------------------------------------------------------------
# Shipments
#
# The shipment form is stricter about *completeness*. The model tolerates a
# blank vessel, a blank container and a missing date, because records also
# arrive through the API. But a shipment is what the buyer's page shows once a
# batch is on the water, so the portal form requires the fields the buyer
# reads — status, vessel, container, ETD and ETA. The bill of lading and the
# forwarder arrive later in the process and stay optional, and the ports keep
# their model defaults. A departure after the arrival is refused: it is a typo,
# not a plan.
#
# The shipment's status and the batch's journey status are separate. The batch
# dropdown labels each batch with its own status so the two can be seen side by
# side, but nothing here moves the batch, and the batch screen does not move a
# shipment.
class BatchChoiceField(forms.ModelChoiceField):
"""Batch dropdown that carries the batch's own journey status.
The two statuses are independent, so showing the batch's current one here
lets an operator see them together without a second lookup. The count of
*other* shipments on the batch is shown too, so a likely duplicate is
visible before saving — the form does not block one, because a split
consignment is legitimate.
"""
def label_from_instance(self, batch):
label = f"{batch.batch_reference} — {batch.get_status_display()}"
others = getattr(batch, "shipment_count", 0)
if others:
label += f" ({others} other shipment{'s' if others != 1 else ''})"
return label
class ShipmentForm(forms.ModelForm):
"""Create or correct a shipment."""
batch = BatchChoiceField(
queryset=ProcessingBatch.objects.order_by("-created_at"),
label="Batch",
help_text=(
"The consignment's batch. Its own journey status is shown here; the "
"shipment status below is separate."
),
empty_label="Select the batch",
)
status = forms.ChoiceField(
choices=Shipment.Status.choices,
label="Shipment status",
help_text=(
"Where the consignment is. It moves freely between these; the buyer's "
"page reflects the new status immediately."
),
widget=forms.Select(attrs={"class": "form-select"}),
)
class Meta:
model = Shipment
fields = [
"batch",
"status",
"origin_port",
"destination_port",
"vessel_name",
"container_ref",
"etd_nigeria",
"eta_uk",
"bl_number",
"freight_forwarder",
]
labels = {
"origin_port": "Origin port",
"destination_port": "Destination port",
"vessel_name": "Vessel name",
"container_ref": "Container reference",
"etd_nigeria": "ETD Nigeria",
"eta_uk": "ETA UK",
"bl_number": "Bill of lading number",
"freight_forwarder": "Freight forwarder",
}
help_texts = {
"origin_port": "Where it leaves Nigeria.",
"destination_port": "Where it arrives in the United Kingdom.",
"vessel_name": "The vessel carrying it.",
"container_ref": "The container reference the buyer is given.",
"etd_nigeria": "Estimated departure from the Nigerian port.",
"eta_uk": "Estimated arrival in the United Kingdom.",
"bl_number": "Optional — the bill of lading usually arrives after booking.",
"freight_forwarder": "Optional — the forwarding agent.",
}
widgets = {
"origin_port": forms.TextInput(attrs={"class": "form-control"}),
"destination_port": forms.TextInput(attrs={"class": "form-control"}),
"vessel_name": forms.TextInput(attrs={"class": "form-control"}),
"container_ref": forms.TextInput(attrs={"class": "form-control"}),
"etd_nigeria": forms.DateInput(
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
),
"eta_uk": forms.DateInput(
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
),
"bl_number": forms.TextInput(attrs={"class": "form-control"}),
"freight_forwarder": forms.TextInput(attrs={"class": "form-control"}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# The model tolerates blanks and missing dates; the buyer's page does not.
for name in (
"batch",
"status",
"vessel_name",
"container_ref",
"etd_nigeria",
"eta_uk",
):
self.fields[name].required = True
# Count the *other* shipments on each batch, so the dropdown can flag a
# batch that already has one. Excluding this shipment means an edit does
# not count itself.
others = ~Q(shipments=self.instance) if self.instance.pk else Q()
self.fields["batch"].queryset = ProcessingBatch.objects.annotate(
shipment_count=Count("shipments", filter=others)
).order_by("-created_at")
def clean(self):
cleaned = super().clean()
etd = cleaned.get("etd_nigeria")
eta = cleaned.get("eta_uk")
if etd and eta and etd > eta:
self.add_error(
"eta_uk",
forms.ValidationError(
"The ETA cannot be before the ETD. Check the two dates."
),
)
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
# ---------------------------------------------------------------------------
# Product specifications
#
# A specification is the set of limits every QC result is judged against
# (``ProductSpecification.current()`` reads status=approved and is_current=True).
# The portal lets operations create and edit a specification and its parameters
# together, mirroring the admin's inline.
#
# The **approval half is deliberately held**: the form does not expose status,
# is_current, approved_by or approved_at, so a specification created here is a
# draft and cannot be approved from the portal. Approval is a single field edit
# in admin today and there is no four-eyes service for it, so it is held until
# the owner decides who may approve one. The edit screen warns when the record
# being changed is already approved, because its parameters are live limits.
class SpecificationForm(forms.ModelForm):
class Meta:
model = ProductSpecification
fields = ["product", "version", "effective_from", "effective_to"]
labels = {
"product": "Product",
"version": "Version",
"effective_from": "Effective from",
"effective_to": "Effective to",
}
help_texts = {
"product": "The product the specification applies to, e.g. Unrefined Shea Butter.",
"version": "The version label. Must be unique for the product.",
"effective_from": "Optional. The date this version starts to apply.",
"effective_to": "Optional. The date it stops applying.",
}
widgets = {
"product": forms.TextInput(attrs={"class": "form-control"}),
"version": forms.TextInput(attrs={"class": "form-control", "autocomplete": "off"}),
"effective_from": forms.DateInput(
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
),
"effective_to": forms.DateInput(
format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"}
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["product"].required = True
self.fields["version"].required = True
def clean(self):
cleaned = super().clean()
product = (cleaned.get("product") or "").strip()
version = (cleaned.get("version") or "").strip()
cleaned["product"] = product
cleaned["version"] = version
if product and version:
clashes = ProductSpecification.objects.filter(
product__iexact=product, version__iexact=version
)
if self.instance.pk:
clashes = clashes.exclude(pk=self.instance.pk)
if clashes.exists():
self.add_error(
"version",
forms.ValidationError(
f"{product} version {version} already exists. Versions must be "
"unique for a product."
),
)
start = cleaned.get("effective_from")
end = cleaned.get("effective_to")
if start and end and end < start:
self.add_error(
"effective_to",
forms.ValidationError(
"The effective-to date cannot be before the effective-from date."
),
)
return cleaned
class SpecParameterForm(forms.ModelForm):
"""One limit line on a specification."""
class Meta:
model = SpecParameter
fields = [
"parameter",
"unit",
"min_value",
"max_value",
"allowed_values",
"required",
"sort_order",
]
labels = {
"parameter": "Parameter",
"unit": "Unit",
"min_value": "Minimum",
"max_value": "Maximum",
"allowed_values": "Allowed values",
"required": "Required",
"sort_order": "Order",
}
help_texts = {
"unit": "e.g. %, meq O2/kg.",
"min_value": "Numeric parameters only. Leave blank if there is no lower limit.",
"max_value": "Numeric parameters only. Leave blank if there is no upper limit.",
"allowed_values": "Visual grade only. A comma-separated list, e.g. A,B,C.",
"required": "Whether a QC result must carry this value.",
"sort_order": "The order the parameter is shown in.",
}
widgets = {
"parameter": forms.Select(attrs={"class": "form-select"}),
"unit": forms.TextInput(attrs={"class": "form-control"}),
"min_value": forms.NumberInput(
attrs={"class": "form-control", "step": "0.001"}
),
"max_value": forms.NumberInput(
attrs={"class": "form-control", "step": "0.001"}
),
"allowed_values": forms.TextInput(attrs={"class": "form-control"}),
"required": forms.CheckboxInput(attrs={"class": "form-check-input"}),
"sort_order": forms.NumberInput(attrs={"class": "form-control"}),
}
def clean(self):
cleaned = super().clean()
if cleaned.get("DELETE"):
# A row the user is removing does not need to be well formed.
return cleaned
parameter = cleaned.get("parameter")
allowed = (cleaned.get("allowed_values") or "").strip()
minimum = cleaned.get("min_value")
maximum = cleaned.get("max_value")
if parameter == SpecParameter.Parameter.VISUAL:
if not allowed:
self.add_error(
"allowed_values",
forms.ValidationError(
"A visual parameter needs the allowed grades, e.g. A,B,C."
),
)
elif minimum is None and maximum is None:
self.add_error(
"min_value",
forms.ValidationError(
"A numeric parameter needs a minimum, a maximum, or both. With "
"no limits it would pass every value."
),
)
if minimum is not None and maximum is not None and minimum > maximum:
self.add_error(
"max_value",
forms.ValidationError("The maximum cannot be below the minimum."),
)
return cleaned
class BaseSpecParameterFormSet(forms.BaseInlineFormSet):
def clean(self):
super().clean()
if any(self.errors):
return
alive = [
form
for form in self.forms
if getattr(form, "cleaned_data", None) and not form.cleaned_data.get("DELETE")
]
if not alive:
raise forms.ValidationError(
"A specification needs at least one parameter. With no parameters, "
"every QC result would pass."
)
SpecParameterFormSet = inlineformset_factory(
ProductSpecification,
SpecParameter,
form=SpecParameterForm,
formset=BaseSpecParameterFormSet,
extra=1,
can_delete=True,
)