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.
658 lines
27 KiB
Python
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
|