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/).
944 lines
40 KiB
Python
944 lines
40 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 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,
|
|
)
|
|
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
|