diff --git a/apps/ops/forms.py b/apps/ops/forms.py index 9c66b08..63e10c4 100644 --- a/apps/ops/forms.py +++ b/apps/ops/forms.py @@ -22,7 +22,7 @@ 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 django.forms import inlineformset_factory, modelformset_factory from apps.batches.models import ProcessingBatch from apps.compliance import services @@ -35,6 +35,8 @@ from apps.compliance.models import ( IntakeBatchLineage, MitigationAction, OriginZone, + ProductSpecification, + SpecParameter, ) from apps.shipments.models import Shipment @@ -942,3 +944,193 @@ class StatementReferenceForm(forms.ModelForm): if not reference: raise forms.ValidationError("A DDS reference is required.") return reference + + +# --------------------------------------------------------------------------- +# Product specifications +# +# A specification is the set of limits every QC result is judged against +# (``ProductSpecification.current()`` reads status=approved and is_current=True). +# The portal lets operations create and edit a specification and its parameters +# together, mirroring the admin's inline. +# +# The **approval half is deliberately held**: the form does not expose status, +# is_current, approved_by or approved_at, so a specification created here is a +# draft and cannot be approved from the portal. Approval is a single field edit +# in admin today and there is no four-eyes service for it, so it is held until +# the owner decides who may approve one. The edit screen warns when the record +# being changed is already approved, because its parameters are live limits. + + +class SpecificationForm(forms.ModelForm): + class Meta: + model = ProductSpecification + fields = ["product", "version", "effective_from", "effective_to"] + labels = { + "product": "Product", + "version": "Version", + "effective_from": "Effective from", + "effective_to": "Effective to", + } + help_texts = { + "product": "The product the specification applies to, e.g. Unrefined Shea Butter.", + "version": "The version label. Must be unique for the product.", + "effective_from": "Optional. The date this version starts to apply.", + "effective_to": "Optional. The date it stops applying.", + } + widgets = { + "product": forms.TextInput(attrs={"class": "form-control"}), + "version": forms.TextInput(attrs={"class": "form-control", "autocomplete": "off"}), + "effective_from": forms.DateInput( + format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"} + ), + "effective_to": forms.DateInput( + format="%Y-%m-%d", attrs={"class": "form-control", "type": "date"} + ), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["product"].required = True + self.fields["version"].required = True + + def clean(self): + cleaned = super().clean() + product = (cleaned.get("product") or "").strip() + version = (cleaned.get("version") or "").strip() + cleaned["product"] = product + cleaned["version"] = version + + if product and version: + clashes = ProductSpecification.objects.filter( + product__iexact=product, version__iexact=version + ) + if self.instance.pk: + clashes = clashes.exclude(pk=self.instance.pk) + if clashes.exists(): + self.add_error( + "version", + forms.ValidationError( + f"{product} version {version} already exists. Versions must be " + "unique for a product." + ), + ) + + start = cleaned.get("effective_from") + end = cleaned.get("effective_to") + if start and end and end < start: + self.add_error( + "effective_to", + forms.ValidationError( + "The effective-to date cannot be before the effective-from date." + ), + ) + return cleaned + + +class SpecParameterForm(forms.ModelForm): + """One limit line on a specification.""" + + class Meta: + model = SpecParameter + fields = [ + "parameter", + "unit", + "min_value", + "max_value", + "allowed_values", + "required", + "sort_order", + ] + labels = { + "parameter": "Parameter", + "unit": "Unit", + "min_value": "Minimum", + "max_value": "Maximum", + "allowed_values": "Allowed values", + "required": "Required", + "sort_order": "Order", + } + help_texts = { + "unit": "e.g. %, meq O2/kg.", + "min_value": "Numeric parameters only. Leave blank if there is no lower limit.", + "max_value": "Numeric parameters only. Leave blank if there is no upper limit.", + "allowed_values": "Visual grade only. A comma-separated list, e.g. A,B,C.", + "required": "Whether a QC result must carry this value.", + "sort_order": "The order the parameter is shown in.", + } + widgets = { + "parameter": forms.Select(attrs={"class": "form-select"}), + "unit": forms.TextInput(attrs={"class": "form-control"}), + "min_value": forms.NumberInput( + attrs={"class": "form-control", "step": "0.001"} + ), + "max_value": forms.NumberInput( + attrs={"class": "form-control", "step": "0.001"} + ), + "allowed_values": forms.TextInput(attrs={"class": "form-control"}), + "required": forms.CheckboxInput(attrs={"class": "form-check-input"}), + "sort_order": forms.NumberInput(attrs={"class": "form-control"}), + } + + def clean(self): + cleaned = super().clean() + if cleaned.get("DELETE"): + # A row the user is removing does not need to be well formed. + return cleaned + + parameter = cleaned.get("parameter") + allowed = (cleaned.get("allowed_values") or "").strip() + minimum = cleaned.get("min_value") + maximum = cleaned.get("max_value") + + if parameter == SpecParameter.Parameter.VISUAL: + if not allowed: + self.add_error( + "allowed_values", + forms.ValidationError( + "A visual parameter needs the allowed grades, e.g. A,B,C." + ), + ) + elif minimum is None and maximum is None: + self.add_error( + "min_value", + forms.ValidationError( + "A numeric parameter needs a minimum, a maximum, or both. With " + "no limits it would pass every value." + ), + ) + + if minimum is not None and maximum is not None and minimum > maximum: + self.add_error( + "max_value", + forms.ValidationError("The maximum cannot be below the minimum."), + ) + return cleaned + + +class BaseSpecParameterFormSet(forms.BaseInlineFormSet): + def clean(self): + super().clean() + if any(self.errors): + return + alive = [ + form + for form in self.forms + if getattr(form, "cleaned_data", None) and not form.cleaned_data.get("DELETE") + ] + if not alive: + raise forms.ValidationError( + "A specification needs at least one parameter. With no parameters, " + "every QC result would pass." + ) + + +SpecParameterFormSet = inlineformset_factory( + ProductSpecification, + SpecParameter, + form=SpecParameterForm, + formset=BaseSpecParameterFormSet, + extra=1, + can_delete=True, +) diff --git a/apps/ops/tests.py b/apps/ops/tests.py index 23325cf..c97e09a 100644 --- a/apps/ops/tests.py +++ b/apps/ops/tests.py @@ -25,6 +25,8 @@ from apps.compliance.models import ( IntakeBatchLineage, MitigationAction, OriginZone, + ProductSpecification, + SpecParameter, ) from apps.shipments.models import Shipment @@ -1256,3 +1258,219 @@ class StatementReferenceEditTests(EUDRCaseChildBase): ) self.assertEqual(r.status_code, 200) self.assertTrue(r.context["form"].errors.get("dds_reference")) + + +# --------------------------------------------------------------------------- +# Product specifications +# +# The portal can create and edit a specification and its parameters, but the +# approval half is held: a portal-created specification is a draft and the form +# cannot set status or is_current. These tests pin both halves. + + +class SpecificationBase(TestCase): + def setUp(self): + self.ops = User.objects.create_user( + email="spec-ops@ixg.local", password="pass1234", role="ops", + full_name="Spec Officer", is_staff=True, + ) + self.buyer = User.objects.create_user( + email="spec-buyer@ixg.local", password="pass1234", role="buyer", + full_name="Spec Buyer", is_staff=True, + ) + 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.spec = ProductSpecification.objects.create( + product="Unrefined Shea Butter", version="7.1", + status=ProductSpecification.Status.APPROVED, is_current=True, + ) + self.ffa = SpecParameter.objects.create( + spec=self.spec, parameter=SpecParameter.Parameter.FFA, unit="%", + max_value=Decimal("3.000"), sort_order=1, + ) + + def management(self, total, initial=0): + return { + "params-TOTAL_FORMS": str(total), + "params-INITIAL_FORMS": str(initial), + "params-MIN_NUM_FORMS": "0", + "params-MAX_NUM_FORMS": "1000", + } + + def create_payload(self, **overrides): + data = { + "product": "Unrefined Shea Butter", + "version": "2.0", + "effective_from": "", + "effective_to": "", + **self.management(1), + "params-0-id": "", + "params-0-parameter": "moisture", + "params-0-unit": "%", + "params-0-min_value": "", + "params-0-max_value": "0.100", + "params-0-allowed_values": "", + "params-0-required": "on", + "params-0-sort_order": "1", + "params-0-DELETE": "", + } + data.update(overrides) + return data + + def edit_payload(self, params, **overrides): + data = { + "product": self.spec.product, + "version": self.spec.version, + "effective_from": "", + "effective_to": "", + **self.management(len(params), initial=len(params)), + } + for i, p in enumerate(params): + data[f"params-{i}-id"] = str(p.pk) + data[f"params-{i}-parameter"] = p.parameter + data[f"params-{i}-unit"] = p.unit + data[f"params-{i}-min_value"] = str(p.min_value) if p.min_value is not None else "" + data[f"params-{i}-max_value"] = str(p.max_value) if p.max_value is not None else "" + data[f"params-{i}-allowed_values"] = p.allowed_values + data[f"params-{i}-required"] = "on" if p.required else "" + data[f"params-{i}-sort_order"] = str(p.sort_order) + data[f"params-{i}-DELETE"] = "" + data.update(overrides) + return data + + +class SpecificationAccessTests(SpecificationBase): + def test_ops_can_open_the_specification_screens(self): + for path in ( + "/ops/specifications/", + "/ops/specifications/new/", + f"/ops/specifications/{self.spec.pk}/edit/", + ): + with self.subTest(path=path): + self.assertEqual(self.client_ops.get(path).status_code, 200) + + def test_a_buyer_cannot_open_the_specification_screens(self): + for path in ( + "/ops/specifications/", + "/ops/specifications/new/", + f"/ops/specifications/{self.spec.pk}/edit/", + ): + with self.subTest(path=path): + self.assertEqual(self.client_buyer.get(path).status_code, 403) + + def test_the_sidebar_links_to_specifications(self): + self.assertContains(self.client_ops.get("/ops/specifications/"), "Specifications") + + +class SpecificationCreateTests(SpecificationBase): + def test_a_specification_is_created_as_a_draft_with_its_parameters(self): + r = self.client_ops.post( + "/ops/specifications/new/", self.create_payload(), follow=True + ) + self.assertEqual(r.status_code, 200) + spec = ProductSpecification.objects.get(version="2.0") + self.assertEqual(spec.status, ProductSpecification.Status.DRAFT) + self.assertFalse(spec.is_current) + self.assertEqual(spec.parameters.count(), 1) + self.assertEqual(spec.parameters.first().parameter, "moisture") + + def test_the_approval_fields_cannot_be_set_from_the_portal(self): + r = self.client_ops.post( + "/ops/specifications/new/", + self.create_payload(status="approved", is_current="on"), + ) + self.assertEqual(r.status_code, 302) + spec = ProductSpecification.objects.get(version="2.0") + self.assertEqual(spec.status, ProductSpecification.Status.DRAFT) + self.assertFalse(spec.is_current) + + def test_a_specification_with_no_parameters_is_refused(self): + payload = self.create_payload(**self.management(0)) + for key in list(payload): + if key.startswith("params-0-"): + del payload[key] + r = self.client_ops.post("/ops/specifications/new/", payload) + self.assertEqual(r.status_code, 200) + errors = " ".join(str(e) for e in r.context["formset"].non_form_errors()) + self.assertIn("at least one parameter", errors) + + def test_a_duplicate_product_and_version_is_refused(self): + r = self.client_ops.post( + "/ops/specifications/new/", + self.create_payload(product=self.spec.product, version=self.spec.version), + ) + self.assertEqual(r.status_code, 200) + self.assertTrue(r.context["form"].errors.get("version")) + + def test_a_numeric_parameter_with_no_limits_is_refused(self): + r = self.client_ops.post( + "/ops/specifications/new/", + self.create_payload(**{"params-0-max_value": ""}), + ) + self.assertEqual(r.status_code, 200) + form = r.context["formset"].forms[0] + self.assertTrue(form.errors.get("min_value")) + + def test_a_visual_parameter_without_allowed_values_is_refused(self): + r = self.client_ops.post( + "/ops/specifications/new/", + self.create_payload(**{ + "params-0-parameter": "visual", + "params-0-max_value": "", + "params-0-allowed_values": "", + }), + ) + self.assertEqual(r.status_code, 200) + form = r.context["formset"].forms[0] + self.assertTrue(form.errors.get("allowed_values")) + + def test_an_effective_to_before_effective_from_is_refused(self): + r = self.client_ops.post( + "/ops/specifications/new/", + self.create_payload(effective_from="2026-10-01", effective_to="2026-09-01"), + ) + self.assertEqual(r.status_code, 200) + self.assertTrue(r.context["form"].errors.get("effective_to")) + + +class SpecificationEditTests(SpecificationBase): + def test_editing_updates_a_parameter_in_place(self): + before = ProductSpecification.objects.count() + self.ffa.max_value = Decimal("2.500") + r = self.client_ops.post( + f"/ops/specifications/{self.spec.pk}/edit/", + self.edit_payload([self.ffa]), + follow=True, + ) + self.assertEqual(r.status_code, 200) + self.ffa.refresh_from_db() + self.assertEqual(self.ffa.max_value, Decimal("2.500")) + self.assertEqual(ProductSpecification.objects.count(), before) + + def test_a_parameter_can_be_removed(self): + visual = SpecParameter.objects.create( + spec=self.spec, parameter=SpecParameter.Parameter.VISUAL, + allowed_values="A,B,C", sort_order=2, + ) + payload = self.edit_payload([self.ffa, visual]) + payload["params-1-DELETE"] = "on" + r = self.client_ops.post( + f"/ops/specifications/{self.spec.pk}/edit/", payload + ) + self.assertEqual(r.status_code, 302) + self.assertEqual(self.spec.parameters.count(), 1) + + def test_editing_an_approved_specification_warns_and_keeps_its_status(self): + r = self.client_ops.get(f"/ops/specifications/{self.spec.pk}/edit/") + self.assertContains(r, "This specification is not a draft") + self.client_ops.post( + f"/ops/specifications/{self.spec.pk}/edit/", self.edit_payload([self.ffa]) + ) + self.spec.refresh_from_db() + self.assertEqual(self.spec.status, ProductSpecification.Status.APPROVED) + + def test_a_buyer_cannot_post_a_specification(self): + r = self.client_buyer.post("/ops/specifications/new/", self.create_payload()) + self.assertEqual(r.status_code, 403) diff --git a/apps/ops/urls.py b/apps/ops/urls.py index 9f0d9a1..2cda7e5 100644 --- a/apps/ops/urls.py +++ b/apps/ops/urls.py @@ -22,6 +22,9 @@ urlpatterns = [ path('collector-groups/', views.CollectorGroupListView.as_view(), name='collector_group_list'), path('collector-groups/new/', views.CollectorGroupCreateView.as_view(), name='collector_group_create'), path('collector-groups//edit/', views.CollectorGroupUpdateView.as_view(), name='collector_group_update'), + path('specifications/', views.SpecificationListView.as_view(), name='specification_list'), + path('specifications/new/', views.SpecificationCreateView.as_view(), name='specification_create'), + path('specifications//edit/', views.SpecificationUpdateView.as_view(), name='specification_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 543c613..3465ba1 100644 --- a/apps/ops/views.py +++ b/apps/ops/views.py @@ -29,6 +29,7 @@ from apps.compliance.models import ( IntakeBatchLineage, MitigationAction, OriginZone, + ProductSpecification, ) from apps.compliance import services from apps.guides.views import GuideDetailView as BaseGuideDetailView @@ -43,6 +44,8 @@ from .forms import ( MitigationActionForm, OriginZoneForm, ShipmentForm, + SpecificationForm, + SpecParameterFormSet, StatementReferenceForm, ) @@ -1173,3 +1176,117 @@ class StatementReferenceUpdateView(CaseChildMixin, UpdateView): response = super().form_valid(form) messages.success(self.request, 'Submission reference updated.') return response + + +# --------------------------------------------------------------------------- +# Product specifications +# +# A specification is the set of limits every QC result is judged against. The +# portal can create and edit a specification and its parameters together, but +# the **approval half is held**: status, is_current, approved_by and approved_at +# are not on the form, so a portal-created specification is a draft and cannot +# be approved here. See apps/ops/forms.py for the reasoning. + + +class SpecificationListView(OpsRequiredMixin, ListView): + model = ProductSpecification + template_name = 'ops/specification_list.html' + context_object_name = 'specs' + paginate_by = 25 + + def get_queryset(self): + qs = ProductSpecification.objects.prefetch_related('parameters').order_by( + 'product', '-version' + ) + product = self.request.GET.get('product') + status = self.request.GET.get('status') + if product: + qs = qs.filter(product__icontains=product) + if status: + qs = qs.filter(status=status) + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['status_choices'] = ProductSpecification.Status.choices + ctx['current_product'] = self.request.GET.get('product', '') + ctx['current_status'] = self.request.GET.get('status', '') + return ctx + + +class SpecificationFormContextMixin: + model = ProductSpecification + form_class = SpecificationForm + template_name = 'ops/specification_form.html' + + def get_formset(self, data=None): + instance = self.object if self.object is not None else ProductSpecification() + return SpecParameterFormSet(data, instance=instance, prefix='params') + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + if 'formset' not in ctx: + ctx['formset'] = self.get_formset() + ctx['cancel_url'] = reverse('ops:specification_list') + spec = getattr(self, 'object', None) + ctx['editing_approved'] = bool( + spec is not None + and spec.pk + and spec.status != ProductSpecification.Status.DRAFT + ) + return ctx + + def forms_valid(self, form, formset): + self.object = form.save(commit=False) + if self.object._state.adding: + # The approval fields are not on the form; a portal-created spec is + # always a draft, never current. + self.object.status = ProductSpecification.Status.DRAFT + self.object.is_current = False + self.object.save() + formset.instance = self.object + formset.save() + messages.success( + self.request, + f"Specification {self.object.product} v{self.object.version} saved.", + ) + return redirect(reverse('ops:specification_list')) + + def forms_invalid(self, form, formset): + return self.render_to_response( + self.get_context_data(form=form, formset=formset) + ) + + +class SpecificationCreateView(SpecificationFormContextMixin, OpsRequiredMixin, CreateView): + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['page_title'] = 'New product specification' + ctx['submit_label'] = 'Create specification' + return ctx + + def post(self, request, *args, **kwargs): + self.object = None + form = self.get_form() + formset = self.get_formset(request.POST) + if form.is_valid() and formset.is_valid(): + return self.forms_valid(form, formset) + return self.forms_invalid(form, formset) + + +class SpecificationUpdateView(SpecificationFormContextMixin, OpsRequiredMixin, UpdateView): + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['page_title'] = ( + f"Edit specification {self.object.product} v{self.object.version}" + ) + ctx['submit_label'] = 'Save changes' + return ctx + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.get_form() + formset = self.get_formset(request.POST) + if form.is_valid() and formset.is_valid(): + return self.forms_valid(form, formset) + return self.forms_invalid(form, formset) diff --git a/templates/ops/base_ops.html b/templates/ops/base_ops.html index 1266856..c12ad74 100644 --- a/templates/ops/base_ops.html +++ b/templates/ops/base_ops.html @@ -41,6 +41,7 @@ body { font-size: .875rem; background: #f5f6fa; } + diff --git a/templates/ops/specification_form.html b/templates/ops/specification_form.html new file mode 100644 index 0000000..631e3ee --- /dev/null +++ b/templates/ops/specification_form.html @@ -0,0 +1,203 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}{{ page_title }} - IXG Operations{% endblock %} +{% block content %} + + + +{% if messages %} + {% for m in messages %}
{{ m }}
{% endfor %} +{% endif %} + +{% if editing_approved %} +
+ This specification is not a draft. Its parameters are the limits QC results + are judged against, so editing them changes those limits. Approval is not available in the + portal yet — it is held until the approval control is decided. +
+{% endif %} + +
+ {% csrf_token %} +
+
+
+
Specification
+ {% if form.non_field_errors %} + {% for e in form.non_field_errors %}
{{ e }}
{% endfor %} + {% endif %} + {% for field in form %} +
+ + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for e in field.errors %}
{{ e }}
{% endfor %} +
+ {% endfor %} +
+ +
+
+
Parameters
+ +
+ + {{ formset.management_form }} + {% if formset.non_form_errors %} + {% for e in formset.non_form_errors %}
{{ e }}
{% endfor %} + {% endif %} + +
+ {% for pf in formset %} +
+ {{ pf.id }} +
+
+ + {{ pf.parameter }} + {% for e in pf.parameter.errors %}
{{ e }}
{% endfor %} +
+
+ + {{ pf.unit }} +
+
+ + {{ pf.min_value }} + {% for e in pf.min_value.errors %}
{{ e }}
{% endfor %} +
+
+ + {{ pf.max_value }} + {% for e in pf.max_value.errors %}
{{ e }}
{% endfor %} +
+
+ + {{ pf.sort_order }} +
+
+ + {{ pf.allowed_values }} + {% for e in pf.allowed_values.errors %}
{{ e }}
{% endfor %} +
+
+
+ {{ pf.required }} + +
+
+
+
+ {{ pf.DELETE }} + +
+
+
+ {% for e in pf.non_field_errors %}
{{ e }}
{% endfor %} +
+ {% endfor %} +
+
+
+ +
+
+
How a specification is used
+

+ Each parameter is a limit. A numeric parameter is checked between its minimum and + maximum; a visual parameter is checked against its allowed grades. A QC result is + judged against the specification that is both approved and + current. +

+

+ A specification needs at least one parameter: with none, every QC result would pass. +

+
+ +
+
Approval is held
+

+ Creating and editing is available here, but the portal does not approve a + specification. A new one is saved as a draft. Approving it — the step that makes + its limits the ones QC is judged against — waits for the approval control to be + decided. +

+
+
+
+ +
+ + Cancel +
+
+ + + +{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/ops/specification_list.html b/templates/ops/specification_list.html new file mode 100644 index 0000000..a06c53b --- /dev/null +++ b/templates/ops/specification_list.html @@ -0,0 +1,100 @@ +{% extends 'ops/base_ops.html' %} +{% block title %}Specifications - IXG Operations{% endblock %} +{% block content %} + +{% if messages %} + {% for m in messages %}
{{ m }}
{% endfor %} +{% endif %} + + + +
+ A specification is the set of limits every QC result is judged against. The portal can + create and edit one, but approval is held: a specification created here + is a draft and cannot be approved from the portal yet. +
+ +
+
+
+ +
+
+ +
+
+ + Clear +
+
+
+ +
+ + + + + + + + + + + + + + + {% for s in specs %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
ProductVersionStatusCurrentEffective fromEffective toParameters
{{ s.product }}{{ s.version }} + {% if s.status == 'approved' %} + {{ s.get_status_display }} + {% elif s.status == 'superseded' %} + {{ s.get_status_display }} + {% else %} + {{ s.get_status_display }} + {% endif %} + {% if s.is_current %}{% else %}-{% endif %}{{ s.effective_from|date:'d/m/Y'|default:'-' }}{{ s.effective_to|date:'d/m/Y'|default:'-' }}{{ s.parameters.all|length }}Edit
No specifications yet
+
+ +{% if page_obj.paginator.num_pages > 1 %} + +{% endif %} + +{% endblock %}