Add an intake screen to the ops portal
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.
This commit is contained in:
parent
d5bc06802e
commit
7d7248a23b
9 changed files with 713 additions and 3 deletions
|
|
@ -23,6 +23,8 @@ User = get_user_model()
|
|||
|
||||
OPS_ROUTES = [
|
||||
"/ops/",
|
||||
"/ops/intakes/",
|
||||
"/ops/intakes/new/",
|
||||
"/ops/batches/",
|
||||
"/ops/qc/",
|
||||
"/ops/qc/verify/",
|
||||
|
|
|
|||
156
apps/ops/forms.py
Normal file
156
apps/ops/forms.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
"""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
|
||||
50
apps/ops/static/js/ixg_intake_form.js
Normal file
50
apps/ops/static/js/ixg_intake_form.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/* Fill an intake's origin zone in from the collector group that delivered it.
|
||||
*
|
||||
* A group is registered to a zone, so choosing the group is enough to know where
|
||||
* the material came from; asking for both invites the two records to disagree.
|
||||
* The zone stays editable, and a saved value is never overwritten — a group can
|
||||
* legitimately deliver from more than one area, and a correction here must stick.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function init() {
|
||||
var form = document.getElementById("intake-form");
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
var groupSelect = document.getElementById("id_collector_group");
|
||||
var zoneSelect = document.getElementById("id_origin_zone");
|
||||
if (!groupSelect || !zoneSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
var groupZones = {};
|
||||
try {
|
||||
groupZones = JSON.parse(form.dataset.groupZones || "{}");
|
||||
} catch (err) {
|
||||
groupZones = {};
|
||||
}
|
||||
|
||||
function fillZone() {
|
||||
var zoneId = groupZones[groupSelect.value];
|
||||
if (zoneId) {
|
||||
zoneSelect.value = zoneId;
|
||||
}
|
||||
}
|
||||
|
||||
// Only fill a blank zone on load, so correcting a record does not silently
|
||||
// rewrite the zone that was recorded at the time.
|
||||
if (!zoneSelect.value) {
|
||||
fillZone();
|
||||
}
|
||||
groupSelect.addEventListener("change", fillZone);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
215
apps/ops/tests.py
Normal file
215
apps/ops/tests.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"""The ops intake screens.
|
||||
|
||||
Two things matter here and neither is cosmetic: that only operations can create
|
||||
intake records, and that the form refuses the incomplete records the platform's
|
||||
own checks exist to catch. The model tolerates a missing date, weight or zone
|
||||
because records also arrive by API; the portal form does not.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
|
||||
from apps.compliance.models import CollectorGroup, Facility, HarvestIntake, OriginZone
|
||||
|
||||
from .views import suggested_intake_reference
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class IntakeTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.ops = User.objects.create_user(
|
||||
email="intake-ops@ixg.local", password="pass1234", role="ops",
|
||||
full_name="Intake Officer", is_staff=True,
|
||||
)
|
||||
self.buyer = User.objects.create_user(
|
||||
email="intake-buyer@ixg.local", password="pass1234", role="buyer",
|
||||
full_name="Intake Buyer", is_staff=True,
|
||||
)
|
||||
self.facility = Facility.objects.create(name="IXG Test Facility")
|
||||
self.zone = OriginZone.objects.create(
|
||||
facility=self.facility, zone_code="NG-KT-ZTEST", country="Nigeria",
|
||||
region="Katsina State", verification_status="verified",
|
||||
)
|
||||
self.other_zone = OriginZone.objects.create(
|
||||
facility=self.facility, zone_code="NG-KT-ZTEST2", country="Nigeria",
|
||||
region="Katsina State", verification_status="unverified",
|
||||
)
|
||||
self.group = CollectorGroup.objects.create(
|
||||
name="Test Collective", contact_reference="CG-TEST-001",
|
||||
origin_zone=self.zone,
|
||||
)
|
||||
self.client_ops = Client(SERVER_NAME="ops.ixgltd.com")
|
||||
self.client_ops.force_login(self.ops)
|
||||
self.client_buyer = Client(SERVER_NAME="ops.ixgltd.com")
|
||||
self.client_buyer.force_login(self.buyer)
|
||||
self.intake = HarvestIntake.objects.create(
|
||||
intake_reference="IXG-INTAKE-2026-9001", collector_group=self.group,
|
||||
origin_zone=self.zone, intake_date="2026-09-01",
|
||||
weight_kg=Decimal("500.00"), grade="A", status="received",
|
||||
)
|
||||
|
||||
def payload(self, **overrides):
|
||||
data = {
|
||||
"intake_reference": "IXG-INTAKE-2026-9002",
|
||||
"collector_group": str(self.group.pk),
|
||||
"origin_zone": str(self.zone.pk),
|
||||
"intake_date": "2026-09-10",
|
||||
"weight_kg": "640.00",
|
||||
"grade": "A",
|
||||
"receiving_officer": "Intake Officer",
|
||||
"status": "received",
|
||||
"evidence_file": "weighbridge-ticket-9002.pdf",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
def errors_for(self, response, field=None):
|
||||
form = response.context["form"]
|
||||
if field:
|
||||
return " ".join(form.errors.get(field, []))
|
||||
return " ".join(str(v) for v in form.errors.values())
|
||||
|
||||
|
||||
class IntakeAccessTests(IntakeTestCase):
|
||||
def test_ops_can_open_the_list(self):
|
||||
r = self.client_ops.get("/ops/intakes/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, "IXG-INTAKE-2026-9001")
|
||||
|
||||
def test_ops_can_open_the_create_form(self):
|
||||
self.assertEqual(self.client_ops.get("/ops/intakes/new/").status_code, 200)
|
||||
|
||||
def test_ops_can_open_the_edit_form(self):
|
||||
r = self.client_ops.get(f"/ops/intakes/{self.intake.pk}/edit/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertContains(r, "IXG-INTAKE-2026-9001")
|
||||
|
||||
def test_a_buyer_cannot_open_the_intake_screens(self):
|
||||
for path in ("/ops/intakes/", "/ops/intakes/new/", f"/ops/intakes/{self.intake.pk}/edit/"):
|
||||
with self.subTest(path=path):
|
||||
self.assertEqual(self.client_buyer.get(path).status_code, 403)
|
||||
|
||||
def test_the_intake_list_appears_in_the_sidebar(self):
|
||||
self.assertContains(self.client_ops.get("/ops/intakes/"), "Harvest Intakes")
|
||||
|
||||
|
||||
class IntakeCreateTests(IntakeTestCase):
|
||||
def test_a_complete_intake_is_recorded(self):
|
||||
r = self.client_ops.post("/ops/intakes/new/", self.payload(), follow=True)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
intake = HarvestIntake.objects.get(intake_reference="IXG-INTAKE-2026-9002")
|
||||
self.assertEqual(intake.weight_kg, Decimal("640.00"))
|
||||
self.assertEqual(intake.origin_zone, self.zone)
|
||||
self.assertEqual(intake.collector_group, self.group)
|
||||
self.assertContains(r, "recorded")
|
||||
|
||||
def test_the_form_suggests_the_next_reference_and_today(self):
|
||||
r = self.client_ops.get("/ops/intakes/new/")
|
||||
self.assertContains(r, 'value="IXG-INTAKE-2026-9002"')
|
||||
self.assertContains(r, "Intake Officer")
|
||||
|
||||
def test_a_duplicate_reference_is_refused(self):
|
||||
r = self.client_ops.post(
|
||||
"/ops/intakes/new/", self.payload(intake_reference="IXG-INTAKE-2026-9001")
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIn("already exists", self.errors_for(r, "intake_reference"))
|
||||
self.assertEqual(HarvestIntake.objects.count(), 1)
|
||||
|
||||
def test_a_duplicate_differing_only_in_case_is_refused(self):
|
||||
r = self.client_ops.post(
|
||||
"/ops/intakes/new/", self.payload(intake_reference="ixg-intake-2026-9001")
|
||||
)
|
||||
self.assertIn("already exists", self.errors_for(r, "intake_reference"))
|
||||
|
||||
def test_a_zero_or_negative_weight_is_refused(self):
|
||||
for weight in ("0", "-5"):
|
||||
with self.subTest(weight=weight):
|
||||
r = self.client_ops.post("/ops/intakes/new/", self.payload(weight_kg=weight))
|
||||
self.assertIn("greater than zero", self.errors_for(r, "weight_kg"))
|
||||
|
||||
def test_the_fields_the_checks_read_are_required(self):
|
||||
cases = {
|
||||
"intake_reference": "",
|
||||
"collector_group": "",
|
||||
"origin_zone": "",
|
||||
"intake_date": "",
|
||||
"weight_kg": "",
|
||||
"grade": "",
|
||||
}
|
||||
for field, blank in cases.items():
|
||||
with self.subTest(field=field):
|
||||
r = self.client_ops.post("/ops/intakes/new/", self.payload(**{field: blank}))
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(
|
||||
self.errors_for(r, field), f"{field} was accepted blank"
|
||||
)
|
||||
|
||||
def test_an_unknown_grade_is_refused(self):
|
||||
r = self.client_ops.post("/ops/intakes/new/", self.payload(grade="Z"))
|
||||
self.assertTrue(self.errors_for(r, "grade"))
|
||||
|
||||
def test_a_zone_that_does_not_match_the_group_is_queried(self):
|
||||
r = self.client_ops.post(
|
||||
"/ops/intakes/new/", self.payload(origin_zone=str(self.other_zone.pk))
|
||||
)
|
||||
self.assertIn("registered to", self.errors_for(r, "origin_zone"))
|
||||
|
||||
def test_an_unverified_zone_is_allowed_but_flagged(self):
|
||||
"""Recording against a zone still awaiting verification must stay possible."""
|
||||
group_without_zone = CollectorGroup.objects.create(name="Unzoned Collective")
|
||||
r = self.client_ops.post(
|
||||
"/ops/intakes/new/",
|
||||
self.payload(collector_group=str(group_without_zone.pk),
|
||||
origin_zone=str(self.other_zone.pk)),
|
||||
)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
self.assertTrue(
|
||||
HarvestIntake.objects.filter(intake_reference="IXG-INTAKE-2026-9002").exists()
|
||||
)
|
||||
|
||||
def test_the_form_labels_an_unverified_zone(self):
|
||||
r = self.client_ops.get("/ops/intakes/new/")
|
||||
self.assertContains(r, "unverified")
|
||||
|
||||
|
||||
class IntakeEditTests(IntakeTestCase):
|
||||
def test_an_intake_can_be_corrected(self):
|
||||
data = self.payload(intake_reference="IXG-INTAKE-2026-9001", weight_kg="510.00")
|
||||
r = self.client_ops.post(f"/ops/intakes/{self.intake.pk}/edit/", data, follow=True)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.intake.refresh_from_db()
|
||||
self.assertEqual(self.intake.weight_kg, Decimal("510.00"))
|
||||
self.assertContains(r, "updated")
|
||||
|
||||
def test_saving_an_intake_under_its_own_reference_is_not_a_duplicate(self):
|
||||
data = self.payload(intake_reference="IXG-INTAKE-2026-9001")
|
||||
r = self.client_ops.post(f"/ops/intakes/{self.intake.pk}/edit/", data)
|
||||
self.assertEqual(r.status_code, 302)
|
||||
|
||||
def test_correcting_does_not_create_a_second_record(self):
|
||||
data = self.payload(intake_reference="IXG-INTAKE-2026-9001", grade="B")
|
||||
self.client_ops.post(f"/ops/intakes/{self.intake.pk}/edit/", data)
|
||||
self.assertEqual(HarvestIntake.objects.count(), 1)
|
||||
|
||||
|
||||
class SuggestedReferenceTests(IntakeTestCase):
|
||||
def test_the_suggestion_continues_the_year_sequence(self):
|
||||
HarvestIntake.objects.create(
|
||||
intake_reference="IXG-INTAKE-2026-9042", intake_date="2026-09-02",
|
||||
weight_kg=Decimal("10.00"), grade="A",
|
||||
)
|
||||
self.assertEqual(suggested_intake_reference(), "IXG-INTAKE-2026-9043")
|
||||
|
||||
def test_the_suggestion_ignores_other_years_and_odd_references(self):
|
||||
HarvestIntake.objects.create(
|
||||
intake_reference="IXG-INTAKE-2025-9999", intake_date="2025-09-02",
|
||||
weight_kg=Decimal("10.00"), grade="A",
|
||||
)
|
||||
HarvestIntake.objects.create(
|
||||
intake_reference="IXG-INTAKE-2026-ABC", intake_date="2026-09-02",
|
||||
weight_kg=Decimal("10.00"), grade="A",
|
||||
)
|
||||
self.assertEqual(suggested_intake_reference(), "IXG-INTAKE-2026-9002")
|
||||
|
|
@ -7,6 +7,9 @@ urlpatterns = [
|
|||
path('', views.DashboardView.as_view(), name='dashboard'),
|
||||
path('batches/', views.BatchListView.as_view(), name='batch_list'),
|
||||
path('batches/<uuid:pk>/', views.BatchDetailView.as_view(), name='batch_detail'),
|
||||
path('intakes/', views.IntakeListView.as_view(), name='intake_list'),
|
||||
path('intakes/new/', views.IntakeCreateView.as_view(), name='intake_create'),
|
||||
path('intakes/<uuid:pk>/edit/', views.IntakeUpdateView.as_view(), name='intake_update'),
|
||||
path('qc/', views.QCListView.as_view(), name='qc_list'),
|
||||
path('qc/<uuid:pk>/', views.QCDetailView.as_view(), name='qc_detail'),
|
||||
path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'),
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import json
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.db.models import Count, Q
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView, ListView, DetailView
|
||||
from django.views.generic import TemplateView, ListView, DetailView, CreateView, UpdateView
|
||||
from django.views import View
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
|
|
@ -14,9 +18,28 @@ from apps.documents.downloads import file_response
|
|||
from apps.documents.models import Document, CertificateOfAnalysis
|
||||
from apps.esg.models import ESGFieldReport
|
||||
from apps.notifications.models import Notification
|
||||
from apps.compliance.models import DueDiligenceCase
|
||||
from apps.compliance.models import CollectorGroup, DueDiligenceCase, HarvestIntake
|
||||
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
||||
from apps.guides.views import GuideIndexView as BaseGuideIndexView
|
||||
from .forms import HarvestIntakeForm
|
||||
|
||||
|
||||
def suggested_intake_reference(today=None):
|
||||
"""Next intake reference, continuing this year's sequence.
|
||||
|
||||
Suggested rather than imposed: the reference may have to match a weighbridge
|
||||
ticket, so the field stays editable.
|
||||
"""
|
||||
year = (today or timezone.localdate()).year
|
||||
prefix = f"IXG-INTAKE-{year}-"
|
||||
highest = 0
|
||||
for ref in HarvestIntake.objects.filter(
|
||||
intake_reference__startswith=prefix
|
||||
).values_list("intake_reference", flat=True):
|
||||
tail = ref[len(prefix):]
|
||||
if tail.isdigit():
|
||||
highest = max(highest, int(tail))
|
||||
return f"{prefix}{highest + 1:04d}"
|
||||
|
||||
|
||||
class OpsRequiredMixin(UserPassesTestMixin):
|
||||
|
|
@ -372,7 +395,6 @@ class EUDRCaseDetailView(OpsRequiredMixin, DetailView):
|
|||
return ctx
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
from django.contrib import messages
|
||||
from apps.compliance import services
|
||||
|
||||
case = self.get_object()
|
||||
|
|
@ -402,6 +424,103 @@ class EUDRCaseDetailView(OpsRequiredMixin, DetailView):
|
|||
return redirect(self.request.path)
|
||||
|
||||
|
||||
class IntakeListView(OpsRequiredMixin, ListView):
|
||||
"""Harvest intakes, the first record the business actually creates itself."""
|
||||
|
||||
model = HarvestIntake
|
||||
template_name = 'ops/intake_list.html'
|
||||
context_object_name = 'intakes'
|
||||
paginate_by = 25
|
||||
|
||||
def get_queryset(self):
|
||||
qs = HarvestIntake.objects.select_related(
|
||||
'collector_group', 'origin_zone'
|
||||
).order_by('-intake_date', '-intake_reference')
|
||||
status = self.request.GET.get('status')
|
||||
q = self.request.GET.get('q')
|
||||
if status:
|
||||
qs = qs.filter(status=status)
|
||||
if q:
|
||||
qs = qs.filter(intake_reference__icontains=q)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['status_choices'] = HarvestIntake.Status.choices
|
||||
ctx['current_status'] = self.request.GET.get('status', '')
|
||||
ctx['current_q'] = self.request.GET.get('q', '')
|
||||
return ctx
|
||||
|
||||
|
||||
class IntakeFormContextMixin:
|
||||
"""Shared context for the create and edit forms."""
|
||||
|
||||
template_name = 'ops/intake_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
# Which zone each collector group draws from, so the form can fill the
|
||||
# zone in when a group is chosen. Kept in a data attribute rather than
|
||||
# injected into a script block.
|
||||
ctx['group_zones'] = json.dumps({
|
||||
str(group.pk): str(group.origin_zone_id)
|
||||
for group in CollectorGroup.objects.exclude(origin_zone__isnull=True)
|
||||
})
|
||||
return ctx
|
||||
|
||||
|
||||
class IntakeCreateView(IntakeFormContextMixin, OpsRequiredMixin, CreateView):
|
||||
model = HarvestIntake
|
||||
form_class = HarvestIntakeForm
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
initial['intake_reference'] = suggested_intake_reference()
|
||||
initial['intake_date'] = timezone.localdate()
|
||||
initial['status'] = HarvestIntake.Status.RECEIVED
|
||||
officer = self.request.user.full_name or self.request.user.email
|
||||
if officer:
|
||||
initial['receiving_officer'] = officer
|
||||
return initial
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New harvest intake'
|
||||
ctx['submit_label'] = 'Record intake'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request, f"Intake {self.object.intake_reference} recorded."
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:intake_list')
|
||||
|
||||
|
||||
class IntakeUpdateView(IntakeFormContextMixin, OpsRequiredMixin, UpdateView):
|
||||
model = HarvestIntake
|
||||
form_class = HarvestIntakeForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = f"Correct intake {self.object.intake_reference}"
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request, f"Intake {self.object.intake_reference} updated."
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:intake_list')
|
||||
|
||||
|
||||
class GuideIndexView(OpsRequiredMixin, BaseGuideIndexView):
|
||||
template_name = 'guides/index_ops.html'
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ body { font-size: .875rem; background: #f5f6fa; }
|
|||
<div class=brand>IXG Platform</div>
|
||||
<ul class=nav flex-column>
|
||||
<li class=nav-item><a class=nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %} href={% url 'ops:dashboard' %}><i class=bi bi-speedometer2></i> Dashboard</a></li>
|
||||
<li class=nav-item><a class=nav-link {% if 'intake' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:intake_list' %}><i class=bi bi-basket></i> Harvest Intakes</a></li>
|
||||
<li class=nav-item><a class=nav-link {% if 'batch' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:batch_list' %}><i class=bi bi-box-seam></i> Batches</a></li>
|
||||
<li class=nav-item><a class=nav-link {% if 'qc' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:qc_list' %}><i class=bi bi-clipboard-check></i> QC Results</a></li>
|
||||
<li class=nav-item><a class=nav-link {% if 'verify' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:qc_verify_list' %}><i class=bi bi-person-check></i> QC Approvals</a></li>
|
||||
|
|
|
|||
71
templates/ops/intake_form.html
Normal file
71
templates/ops/intake_form.html
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% load static %}
|
||||
{% block title %}{{ page_title }} - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<h2>{{ page_title }}</h2>
|
||||
<a href={% url 'ops:intake_list' %} class=btn btn-sm btn-outline-secondary><i class=bi bi-arrow-left></i> Back</a>
|
||||
</div>
|
||||
|
||||
<div class=row g-3>
|
||||
<div class=col-md-7>
|
||||
<div class=stat-card>
|
||||
<form method=post novalidate id=intake-form data-group-zones="{{ group_zones }}">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
{% for e in form.non_field_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class=mb-3>
|
||||
<label class=form-label for="{{ field.id_for_label }}">
|
||||
{{ field.label }}{% if field.field.required %} <span class=text-danger>*</span>{% endif %}
|
||||
</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<div class=form-text>{{ field.help_text }}</div>{% endif %}
|
||||
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button type=submit class="btn btn-primary">{{ submit_label }}</button>
|
||||
<a href={% url 'ops:intake_list' %} class="btn btn-outline-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=col-md-5>
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class=mb-3><i class=bi bi-info-circle></i> Recording an intake</h6>
|
||||
<p class="small mb-2">
|
||||
An intake is the moment a collector group hands over shea at the processing
|
||||
site. It is the record a finished batch is traced back to, so the fields
|
||||
marked <span class=text-danger>*</span> are required: they are the ones the
|
||||
platform's due-diligence checks read.
|
||||
</p>
|
||||
<p class="small mb-0">
|
||||
If something is wrong on a saved intake, use <strong>Correct</strong> on the
|
||||
list. Nothing is deleted — the record is amended, so the reference a buyer
|
||||
or an auditor sees stays the same.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class=stat-card>
|
||||
<h6 class=mb-3><i class=bi bi-shield-check></i> What these records feed</h6>
|
||||
<ul class="small mb-0 ps-3">
|
||||
<li><strong>Lineage</strong> — a batch needs at least one intake linked to it</li>
|
||||
<li><strong>Intake data</strong> — a linked intake must have both a date and a weight</li>
|
||||
<li><strong>Geolocation</strong> — every linked intake must point at a <em>verified</em> origin zone</li>
|
||||
</ul>
|
||||
<p class="small text-secondary mt-2 mb-0">
|
||||
A missing date, weight or zone holds the batch's EUDR case back until it is
|
||||
corrected here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{% static 'js/ixg_intake_form.js' %}"></script>
|
||||
|
||||
{% endblock %}
|
||||
93
templates/ops/intake_list.html
Normal file
93
templates/ops/intake_list.html
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Harvest Intakes - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if messages %}
|
||||
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<h2>Harvest Intakes</h2>
|
||||
<div>
|
||||
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
|
||||
<a href={% url 'ops:intake_create' %} class=btn btn-sm btn-primary><i class=bi bi-plus-lg></i> New intake</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=filter-bar>
|
||||
<form method=get class=row g-2 align-items-end>
|
||||
<div class=col-auto>
|
||||
<input type=text name=q class=form-control form-control-sm placeholder=Search reference... value={{ current_q }}>
|
||||
</div>
|
||||
<div class=col-auto>
|
||||
<select name=status class=form-select form-select-sm>
|
||||
<option value=>All Statuses</option>
|
||||
{% for val, label in status_choices %}
|
||||
<option value={{ val }} {% if current_status == val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class=col-auto>
|
||||
<button type=submit class=btn btn-sm btn-primary>Filter</button>
|
||||
<a href={% url 'ops:intake_list' %} class=btn btn-sm btn-outline-secondary>Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class=stat-card p-0>
|
||||
<table class=table table-hover mb-0>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
<th>Intake Date</th>
|
||||
<th>Weight (kg)</th>
|
||||
<th>Grade</th>
|
||||
<th>Collector Group</th>
|
||||
<th>Origin Zone</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for i in intakes %}
|
||||
<tr>
|
||||
<td><strong>{{ i.intake_reference }}</strong></td>
|
||||
<td>{{ i.intake_date|date:'d/m/Y'|default:'-' }}</td>
|
||||
<td>{{ i.weight_kg|default:'-' }}</td>
|
||||
<td>{{ i.grade|default:'-' }}</td>
|
||||
<td>{{ i.collector_group.name|default:'-' }}</td>
|
||||
<td>
|
||||
{% if i.origin_zone %}
|
||||
{{ i.origin_zone.zone_code }}
|
||||
{% if i.origin_zone.verification_status != 'verified' %}
|
||||
<span class="badge bg-warning text-dark">{{ i.origin_zone.get_verification_status_display }}</span>
|
||||
{% endif %}
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
<td><span class=badge bg-secondary badge-status>{{ i.get_status_display }}</span></td>
|
||||
<td><a href={% url 'ops:intake_update' i.pk %} class=btn btn-sm btn-outline-primary>Correct</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan=8 class=text-muted text-center py-3>No intakes recorded yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if page_obj.paginator.num_pages > 1 %}
|
||||
<nav class=mt-3>
|
||||
<ul class=pagination pagination-sm justify-content-center>
|
||||
{% if page_obj.has_previous %}
|
||||
<li class=page-item><a class=page-link href=?page=1>«</a></li>
|
||||
<li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>
|
||||
{% endif %}
|
||||
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
|
||||
{% if page_obj.has_next %}
|
||||
<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li>
|
||||
<li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
Loading…
Add table
Reference in a new issue