From df2a90bc71a4ec28b50b403da255bdcf2de108b0 Mon Sep 17 00:00:00 2001 From: ixgadmin Date: Fri, 25 Sep 2026 16:35:45 +0000 Subject: [PATCH] Add the product specification screen to the ops portal Operations can now create and edit a specification and its parameters together, over the existing model, 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 portal-created specification is a draft and cannot be approved here. The edit screen warns when the record is not a draft, because its parameters are the limits QC results are judged against. Nothing is removed from the admin. The parameter formset refuses an empty specification (with no parameters every QC result would pass), a numeric parameter with no limits, and a visual parameter with no allowed values. --- apps/ops/forms.py | 194 ++++++++++++++++++++++- apps/ops/tests.py | 218 ++++++++++++++++++++++++++ apps/ops/urls.py | 3 + apps/ops/views.py | 117 ++++++++++++++ templates/ops/base_ops.html | 1 + templates/ops/specification_form.html | 203 ++++++++++++++++++++++++ templates/ops/specification_list.html | 100 ++++++++++++ 7 files changed, 835 insertions(+), 1 deletion(-) create mode 100644 templates/ops/specification_form.html create mode 100644 templates/ops/specification_list.html 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 %}