diff --git a/apps/core/tests.py b/apps/core/tests.py index fe46597..ce8297f 100644 --- a/apps/core/tests.py +++ b/apps/core/tests.py @@ -23,6 +23,8 @@ User = get_user_model() OPS_ROUTES = [ "/ops/", + "/ops/intakes/", + "/ops/intakes/new/", "/ops/batches/", "/ops/qc/", "/ops/qc/verify/", diff --git a/apps/ops/forms.py b/apps/ops/forms.py new file mode 100644 index 0000000..4d2b101 --- /dev/null +++ b/apps/ops/forms.py @@ -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 diff --git a/apps/ops/static/js/ixg_intake_form.js b/apps/ops/static/js/ixg_intake_form.js new file mode 100644 index 0000000..2304155 --- /dev/null +++ b/apps/ops/static/js/ixg_intake_form.js @@ -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(); + } +})(); diff --git a/apps/ops/tests.py b/apps/ops/tests.py new file mode 100644 index 0000000..187b0bd --- /dev/null +++ b/apps/ops/tests.py @@ -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") diff --git a/apps/ops/urls.py b/apps/ops/urls.py index 24add22..0f2a035 100644 --- a/apps/ops/urls.py +++ b/apps/ops/urls.py @@ -7,6 +7,9 @@ urlpatterns = [ path('', views.DashboardView.as_view(), name='dashboard'), path('batches/', views.BatchListView.as_view(), name='batch_list'), path('batches//', 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//edit/', views.IntakeUpdateView.as_view(), name='intake_update'), path('qc/', views.QCListView.as_view(), name='qc_list'), path('qc//', views.QCDetailView.as_view(), name='qc_detail'), path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'), diff --git a/apps/ops/views.py b/apps/ops/views.py index dc7ad06..1d2b394 100644 --- a/apps/ops/views.py +++ b/apps/ops/views.py @@ -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' diff --git a/templates/ops/base_ops.html b/templates/ops/base_ops.html index d92e5a9..0944125 100644 --- a/templates/ops/base_ops.html +++ b/templates/ops/base_ops.html @@ -36,6 +36,7 @@ body { font-size: .875rem; background: #f5f6fa; }
IXG Platform