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.
215 lines
9.2 KiB
Python
215 lines
9.2 KiB
Python
"""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")
|