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.
This commit is contained in:
parent
f6aedc0002
commit
df2a90bc71
7 changed files with 835 additions and 1 deletions
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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/<uuid:pk>/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/<uuid:pk>/edit/', views.SpecificationUpdateView.as_view(), name='specification_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'),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ body { font-size: .875rem; background: #f5f6fa; }
|
|||
<li class="nav-item"><a class="nav-link {% if 'facility' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:facility_list' %}"><i class="bi bi-building"></i> Facilities</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'origin_zone' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:origin_zone_list' %}"><i class="bi bi-geo-alt"></i> Origin Zones</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'collector' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:collector_group_list' %}"><i class="bi bi-people"></i> Collector Groups</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'specification' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:specification_list' %}"><i class="bi bi-clipboard-data"></i> Specifications</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>
|
||||
|
|
|
|||
203
templates/ops/specification_form.html
Normal file
203
templates/ops/specification_form.html
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% 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="{{ cancel_url }}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
|
||||
</div>
|
||||
|
||||
{% if messages %}
|
||||
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if editing_approved %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>This specification is not a draft.</strong> 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.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7">
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3">Specification</h6>
|
||||
{% 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 %}
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Parameters</h6>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" id="add-param"><i class="bi bi-plus-lg"></i> Add parameter</button>
|
||||
</div>
|
||||
|
||||
{{ formset.management_form }}
|
||||
{% if formset.non_form_errors %}
|
||||
{% for e in formset.non_form_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div id="param-rows">
|
||||
{% for pf in formset %}
|
||||
<div class="param-row border rounded p-3 mb-3">
|
||||
{{ pf.id }}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small" for="{{ pf.parameter.id_for_label }}">Parameter</label>
|
||||
{{ pf.parameter }}
|
||||
{% for e in pf.parameter.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small" for="{{ pf.unit.id_for_label }}">Unit</label>
|
||||
{{ pf.unit }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small" for="{{ pf.min_value.id_for_label }}">Minimum</label>
|
||||
{{ pf.min_value }}
|
||||
{% for e in pf.min_value.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small" for="{{ pf.max_value.id_for_label }}">Maximum</label>
|
||||
{{ pf.max_value }}
|
||||
{% for e in pf.max_value.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small" for="{{ pf.sort_order.id_for_label }}">Order</label>
|
||||
{{ pf.sort_order }}
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small" for="{{ pf.allowed_values.id_for_label }}">Allowed values (visual grade)</label>
|
||||
{{ pf.allowed_values }}
|
||||
{% for e in pf.allowed_values.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-center pt-3">
|
||||
<div class="form-check">
|
||||
{{ pf.required }}
|
||||
<label class="form-check-label small" for="{{ pf.required.id_for_label }}">Required</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-center pt-3">
|
||||
<div class="form-check">
|
||||
{{ pf.DELETE }}
|
||||
<label class="form-check-label small text-danger" for="{{ pf.DELETE.id_for_label }}">Remove</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% for e in pf.non_field_errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3"><i class="bi bi-info-circle"></i> How a specification is used</h6>
|
||||
<p class="small mb-2">
|
||||
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 <strong>approved</strong> and
|
||||
<strong>current</strong>.
|
||||
</p>
|
||||
<p class="small mb-0 text-secondary">
|
||||
A specification needs at least one parameter: with none, every QC result would pass.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3"><i class="bi bi-shield-lock"></i> Approval is held</h6>
|
||||
<p class="small mb-0">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<button type="submit" class="btn btn-primary">{{ submit_label }}</button>
|
||||
<a href="{{ cancel_url }}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template id="empty-param-row">
|
||||
<div class="param-row border rounded p-3 mb-3">
|
||||
{{ formset.empty_form.id }}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Parameter</label>
|
||||
{{ formset.empty_form.parameter }}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Unit</label>
|
||||
{{ formset.empty_form.unit }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Minimum</label>
|
||||
{{ formset.empty_form.min_value }}
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Maximum</label>
|
||||
{{ formset.empty_form.max_value }}
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small">Order</label>
|
||||
{{ formset.empty_form.sort_order }}
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small">Allowed values (visual grade)</label>
|
||||
{{ formset.empty_form.allowed_values }}
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-center pt-3">
|
||||
<div class="form-check">
|
||||
{{ formset.empty_form.required }}
|
||||
<label class="form-check-label small">Required</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex align-items-center pt-3">
|
||||
<div class="form-check">
|
||||
{{ formset.empty_form.DELETE }}
|
||||
<label class="form-check-label small text-danger">Remove</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
var total = document.getElementById('id_params-TOTAL_FORMS');
|
||||
var add = document.getElementById('add-param');
|
||||
var tpl = document.getElementById('empty-param-row');
|
||||
var rows = document.getElementById('param-rows');
|
||||
if (!total || !add || !tpl || !rows) { return; }
|
||||
add.addEventListener('click', function () {
|
||||
var index = parseInt(total.value, 10);
|
||||
var html = tpl.innerHTML.replace(/__prefix__/g, index);
|
||||
var holder = document.createElement('div');
|
||||
holder.innerHTML = html.trim();
|
||||
rows.appendChild(holder.firstElementChild);
|
||||
total.value = index + 1;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
100
templates/ops/specification_list.html
Normal file
100
templates/ops/specification_list.html
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Specifications - 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>Product Specifications</h2>
|
||||
<div>
|
||||
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
|
||||
<a href="{% url 'ops:specification_create' %}" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> New specification</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-light border py-2 small">
|
||||
A specification is the set of limits every QC result is judged against. The portal can
|
||||
create and edit one, but <strong>approval is held</strong>: a specification created here
|
||||
is a draft and cannot be approved from the portal yet.
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<input type="text" name="product" class="form-control form-control-sm" placeholder="Search product..." value="{{ current_product }}">
|
||||
</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:specification_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>Product</th>
|
||||
<th>Version</th>
|
||||
<th>Status</th>
|
||||
<th>Current</th>
|
||||
<th>Effective from</th>
|
||||
<th>Effective to</th>
|
||||
<th>Parameters</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for s in specs %}
|
||||
<tr>
|
||||
<td><strong>{{ s.product }}</strong></td>
|
||||
<td>{{ s.version }}</td>
|
||||
<td>
|
||||
{% if s.status == 'approved' %}
|
||||
<span class="badge bg-success">{{ s.get_status_display }}</span>
|
||||
{% elif s.status == 'superseded' %}
|
||||
<span class="badge bg-warning text-dark">{{ s.get_status_display }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">{{ s.get_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if s.is_current %}<i class="bi bi-check-circle-fill text-success"></i>{% else %}-{% endif %}</td>
|
||||
<td>{{ s.effective_from|date:'d/m/Y'|default:'-' }}</td>
|
||||
<td>{{ s.effective_to|date:'d/m/Y'|default:'-' }}</td>
|
||||
<td>{{ s.parameters.all|length }}</td>
|
||||
<td><a href="{% url 'ops:specification_update' s.pk %}" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="8" class="text-muted text-center py-3">No specifications 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