Harvest intakes could only be recorded in the administration area or through the API, so the first record the business actually creates was entered outside the portal it works in. Add list, create and correct screens under Harvest Intakes. The form is deliberately stricter than the model. HarvestIntake allows almost everything to be blank because records also arrive by 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. The form therefore requires every field those checks read: reference, collector group, origin zone, date, weight and grade. Three things it does beyond capturing the fields: it suggests the next intake reference for the year (editable, since it may have to match a weighbridge ticket), fills the origin zone in from the collector group while leaving it editable, and refuses a duplicate reference — including one differing only in case. It queries, rather than blocks, when the chosen zone is not the one the group is registered to, and it marks unverified zones in the dropdown, because geolocation passes only for a verified zone. Correcting amends the record in place: the reference a buyer or auditor quotes does not change.
156 lines
6.8 KiB
Python
156 lines
6.8 KiB
Python
"""Forms for the ops portal.
|
|
|
|
The intake form is deliberately **stricter than the model**. ``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 here — reference, collector group,
|
|
origin zone, date, weight and grade — while the fields that are genuinely
|
|
optional at intake (receiving officer, the supporting file's name) stay optional.
|
|
"""
|
|
from django import forms
|
|
|
|
from apps.compliance.models import CollectorGroup, HarvestIntake, OriginZone
|
|
|
|
# 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"}),
|
|
"intake_date": forms.DateInput(
|
|
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
|