ixg_platform/apps/ops/forms.py
ixgadmin 5e201b2035 Add a shipment entry screen to the ops portal
Walkthrough step 11 said shipment records came through the API or the admin
area, because the portal was read-only. The list and detail pages now carry New
shipment and Edit shipment controls, with /ops/shipments/new/ and
/ops/shipments/<uuid>/edit/ mirroring the intake and batch screens.

The form requires the fields the buyer's page reads — status, vessel, container,
ETD and ETA — and refuses a departure after the arrival. The bill of lading and
the forwarder stay optional, and the ports keep their model defaults. A batch
that already has a shipment is flagged in the dropdown and warned about after
saving rather than blocked, because a split consignment is legitimate. The batch
dropdown labels each batch with its own journey status, and a card explains that
the two statuses are independent.

Also fixes a pre-existing defect the new screen would have shared: the native
date inputs were rendered in the en-gb d/m/Y format, which the browser discards,
so the intake and batch edit forms silently opened with empty dates. They now
carry the ISO format a native date input requires.
2026-09-17 16:03:09 +00:00

658 lines
27 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,
Facility,
HarvestIntake,
IntakeBatchLineage,
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