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.
1292 lines
45 KiB
Python
1292 lines
45 KiB
Python
import json
|
|
|
|
from django.contrib import messages
|
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
|
from django.db.models import Count, Q
|
|
from django.shortcuts import redirect
|
|
from django.urls import reverse
|
|
from django.utils import timezone
|
|
from django.views.generic import TemplateView, ListView, DetailView, CreateView, UpdateView
|
|
from django.views import View
|
|
from django.shortcuts import get_object_or_404, render
|
|
|
|
from apps.batches.models import ProcessingBatch
|
|
from apps.core.tiles import browser_tile_url
|
|
from apps.qc.models import QCResult
|
|
from apps.shipments.models import Shipment
|
|
from apps.capa.models import CAPARecord
|
|
from apps.documents.downloads import file_response
|
|
from apps.documents.models import Document, CertificateOfAnalysis
|
|
from apps.esg.models import ESGFieldReport
|
|
from apps.notifications.models import Notification
|
|
from apps.compliance.models import (
|
|
CollectorGroup,
|
|
DueDiligenceCase,
|
|
DueDiligenceStatementReference,
|
|
EUDREvidence,
|
|
Facility,
|
|
HarvestIntake,
|
|
IntakeBatchLineage,
|
|
MitigationAction,
|
|
OriginZone,
|
|
ProductSpecification,
|
|
)
|
|
from apps.compliance import services
|
|
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
|
from apps.guides.views import GuideIndexView as BaseGuideIndexView
|
|
from .forms import (
|
|
BatchForm,
|
|
BatchLineageFormSet,
|
|
CollectorGroupForm,
|
|
EUDREvidenceForm,
|
|
FacilityForm,
|
|
HarvestIntakeForm,
|
|
MitigationActionForm,
|
|
OriginZoneForm,
|
|
ShipmentForm,
|
|
SpecificationForm,
|
|
SpecParameterFormSet,
|
|
StatementReferenceForm,
|
|
)
|
|
|
|
|
|
def suggested_intake_reference(today=None):
|
|
"""Next intake reference, continuing this year's sequence.
|
|
|
|
Suggested rather than imposed: the reference may have to match a weighbridge
|
|
ticket, so the field stays editable.
|
|
"""
|
|
year = (today or timezone.localdate()).year
|
|
prefix = f"IXG-INTAKE-{year}-"
|
|
highest = 0
|
|
for ref in HarvestIntake.objects.filter(
|
|
intake_reference__startswith=prefix
|
|
).values_list("intake_reference", flat=True):
|
|
tail = ref[len(prefix):]
|
|
if tail.isdigit():
|
|
highest = max(highest, int(tail))
|
|
return f"{prefix}{highest + 1:04d}"
|
|
|
|
|
|
def suggested_batch_reference(today=None):
|
|
"""Next batch reference, continuing this year's sequence. Same reasoning.
|
|
|
|
Three digits, because that is the shape the existing references have
|
|
(``IXG-BATCH-2026-014``); it widens by itself past 999.
|
|
"""
|
|
year = (today or timezone.localdate()).year
|
|
prefix = f"IXG-BATCH-{year}-"
|
|
highest = 0
|
|
for ref in ProcessingBatch.objects.filter(
|
|
batch_reference__startswith=prefix
|
|
).values_list("batch_reference", flat=True):
|
|
tail = ref[len(prefix):]
|
|
if tail.isdigit():
|
|
highest = max(highest, int(tail))
|
|
return f"{prefix}{highest + 1:03d}"
|
|
|
|
|
|
class OpsRequiredMixin(UserPassesTestMixin):
|
|
def test_func(self):
|
|
user = self.request.user
|
|
return user.is_authenticated and (user.role in ('ops', 'admin') or user.is_superuser)
|
|
|
|
|
|
class DashboardView(OpsRequiredMixin, TemplateView):
|
|
template_name = 'ops/dashboard.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['total_batches'] = ProcessingBatch.objects.count()
|
|
ctx['batches_by_status'] = {
|
|
s: ProcessingBatch.objects.filter(status=s).count()
|
|
for s, _ in ProcessingBatch.Status.choices
|
|
}
|
|
ctx['recent_qc'] = QCResult.objects.select_related('batch').order_by('-tested_at')[:10]
|
|
ctx['qc_summary'] = QCResult.objects.aggregate(
|
|
passed=Count('id', filter=Q(overall_pass=True)),
|
|
failed=Count('id', filter=Q(overall_pass=False)),
|
|
)
|
|
ctx['active_shipments'] = Shipment.objects.exclude(status='delivered').count()
|
|
ctx['open_capas'] = CAPARecord.objects.filter(status__in=('open', 'in_progress')).count()
|
|
ctx['recent_notifications'] = Notification.objects.filter(
|
|
user=self.request.user, is_read=False
|
|
)[:5]
|
|
ctx['recent_batches'] = ProcessingBatch.objects.order_by('-created_at')[:5]
|
|
return ctx
|
|
|
|
|
|
class BatchListView(OpsRequiredMixin, ListView):
|
|
model = ProcessingBatch
|
|
template_name = 'ops/batch_list.html'
|
|
context_object_name = 'batches'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = ProcessingBatch.objects.all()
|
|
status = self.request.GET.get('status')
|
|
grade = self.request.GET.get('grade')
|
|
search = self.request.GET.get('q')
|
|
if status:
|
|
qs = qs.filter(status=status)
|
|
if grade:
|
|
qs = qs.filter(grade=grade)
|
|
if search:
|
|
qs = qs.filter(batch_reference__icontains=search)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['status_choices'] = ProcessingBatch.Status.choices
|
|
ctx['grade_choices'] = ProcessingBatch.Grade.choices
|
|
ctx['current_status'] = self.request.GET.get('status', '')
|
|
ctx['current_grade'] = self.request.GET.get('grade', '')
|
|
ctx['current_q'] = self.request.GET.get('q', '')
|
|
return ctx
|
|
|
|
|
|
class BatchDetailView(OpsRequiredMixin, DetailView):
|
|
model = ProcessingBatch
|
|
template_name = 'ops/batch_detail.html'
|
|
context_object_name = 'batch'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
batch = self.object
|
|
ctx['qc_results'] = QCResult.objects.filter(batch=batch)
|
|
ctx['shipments'] = Shipment.objects.filter(batch=batch)
|
|
ctx['capa_records'] = CAPARecord.objects.filter(batch=batch)
|
|
ctx['documents'] = Document.objects.filter(batch=batch)
|
|
ctx['esg_reports'] = ESGFieldReport.objects.filter(batch=batch)
|
|
lineage = batch.intake_lineage.select_related(
|
|
'harvest_intake',
|
|
'harvest_intake__origin_zone',
|
|
'harvest_intake__collector_group',
|
|
).order_by('harvest_intake__intake_reference')
|
|
ctx['lineage'] = lineage
|
|
ctx['lineage_allocated'] = sum((row.weight_allocated_kg or 0) for row in lineage)
|
|
if batch.weight_kg is not None:
|
|
ctx['lineage_difference'] = batch.weight_kg - ctx['lineage_allocated']
|
|
return ctx
|
|
|
|
|
|
class BatchCreateView(OpsRequiredMixin, CreateView):
|
|
model = ProcessingBatch
|
|
form_class = BatchForm
|
|
template_name = 'ops/batch_form.html'
|
|
|
|
def get_initial(self):
|
|
initial = super().get_initial()
|
|
initial['batch_reference'] = suggested_batch_reference()
|
|
initial['harvest_date'] = timezone.localdate()
|
|
initial['status'] = ProcessingBatch.Status.HARVESTED
|
|
return initial
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = 'New processing batch'
|
|
ctx['submit_label'] = 'Create batch'
|
|
# The template cannot ask the instance whether it is new — ProcessingBatch's
|
|
# primary key is a UUID with a default, so an unsaved batch already has one,
|
|
# and `_state` is unreachable from a template. The view knows, so it says.
|
|
ctx['creating'] = True
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
form.instance.created_by = self.request.user
|
|
response = super().form_valid(form)
|
|
messages.success(
|
|
self.request,
|
|
f"Batch {self.object.batch_reference} created. "
|
|
"Allocate its harvest intakes next — a batch with no lineage fails "
|
|
"the due-diligence lineage check.",
|
|
)
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
# Straight to the lineage, because a batch with no intake behind it is
|
|
# not traceable and the check will say so.
|
|
return reverse('ops:batch_lineage', kwargs={'pk': self.object.pk})
|
|
|
|
|
|
class BatchUpdateView(OpsRequiredMixin, UpdateView):
|
|
model = ProcessingBatch
|
|
form_class = BatchForm
|
|
template_name = 'ops/batch_form.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = f"Edit batch {self.object.batch_reference}"
|
|
ctx['submit_label'] = 'Save changes'
|
|
ctx['creating'] = False
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
messages.success(
|
|
self.request, f"Batch {self.object.batch_reference} updated."
|
|
)
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:batch_detail', kwargs={'pk': self.object.pk})
|
|
|
|
|
|
class BatchLineageView(OpsRequiredMixin, View):
|
|
"""Allocate harvest intakes to a batch, and correct what is already there.
|
|
|
|
This is the screen that closes the lineage gap in walkthrough steps 3 and 4:
|
|
the link between an intake and a batch, with the weight that went into it, is
|
|
created here rather than in the administration area. The formset refuses an
|
|
allocation the intake cannot support — see
|
|
``apps/ops/forms.py::BaseBatchLineageFormSet``.
|
|
"""
|
|
|
|
template_name = 'ops/batch_lineage.html'
|
|
|
|
def get_batch(self):
|
|
return get_object_or_404(ProcessingBatch, pk=self.kwargs['pk'])
|
|
|
|
def get_formset(self, batch, data=None):
|
|
queryset = IntakeBatchLineage.objects.filter(batch=batch).select_related(
|
|
'harvest_intake'
|
|
).order_by('harvest_intake__intake_reference')
|
|
return BatchLineageFormSet(
|
|
data,
|
|
queryset=queryset,
|
|
prefix='lineage',
|
|
batch=batch,
|
|
form_kwargs={'batch': batch},
|
|
)
|
|
|
|
def get_context_data(self, batch, formset):
|
|
rows = list(batch.intake_lineage.select_related(
|
|
'harvest_intake', 'harvest_intake__origin_zone',
|
|
'harvest_intake__collector_group',
|
|
).order_by('harvest_intake__intake_reference'))
|
|
allocated = sum((row.weight_allocated_kg or 0) for row in rows)
|
|
return {
|
|
'batch': batch,
|
|
'formset': formset,
|
|
'lineage_rows': rows,
|
|
'total_allocated': allocated,
|
|
'difference': (batch.weight_kg - allocated)
|
|
if batch.weight_kg is not None else None,
|
|
}
|
|
|
|
def get(self, request, *args, **kwargs):
|
|
batch = self.get_batch()
|
|
formset = self.get_formset(batch)
|
|
return render(
|
|
request, self.template_name, self.get_context_data(batch, formset)
|
|
)
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
batch = self.get_batch()
|
|
formset = self.get_formset(batch, data=request.POST)
|
|
if formset.is_valid():
|
|
# A plain ModelFormSet does not know the batch, so the new rows get it
|
|
# here; the deleted ones are removed explicitly.
|
|
for obj in formset.save(commit=False):
|
|
obj.batch = batch
|
|
obj.save()
|
|
for obj in formset.deleted_objects:
|
|
obj.delete()
|
|
messages.success(
|
|
request,
|
|
f"Intake lineage for {batch.batch_reference} updated.",
|
|
)
|
|
return redirect('ops:batch_detail', pk=batch.pk)
|
|
return render(
|
|
request, self.template_name, self.get_context_data(batch, formset)
|
|
)
|
|
|
|
|
|
class QCListView(OpsRequiredMixin, ListView):
|
|
model = QCResult
|
|
template_name = 'ops/qc_list.html'
|
|
context_object_name = 'results'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = QCResult.objects.select_related('batch').all()
|
|
passed = self.request.GET.get('passed')
|
|
if passed == '1':
|
|
qs = qs.filter(overall_pass=True)
|
|
elif passed == '0':
|
|
qs = qs.filter(overall_pass=False)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['current_passed'] = self.request.GET.get('passed', '')
|
|
return ctx
|
|
|
|
|
|
class QCDetailView(OpsRequiredMixin, DetailView):
|
|
model = QCResult
|
|
template_name = 'ops/qc_detail.html'
|
|
context_object_name = 'result'
|
|
|
|
|
|
class ShipmentListView(OpsRequiredMixin, ListView):
|
|
model = Shipment
|
|
template_name = 'ops/shipment_list.html'
|
|
context_object_name = 'shipments'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = Shipment.objects.select_related('batch').all()
|
|
status = self.request.GET.get('status')
|
|
if status:
|
|
qs = qs.filter(status=status)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['status_choices'] = Shipment.Status.choices
|
|
ctx['current_status'] = self.request.GET.get('status', '')
|
|
return ctx
|
|
|
|
|
|
class ShipmentDetailView(OpsRequiredMixin, DetailView):
|
|
model = Shipment
|
|
template_name = 'ops/shipment_detail.html'
|
|
context_object_name = 'shipment'
|
|
|
|
|
|
class ShipmentFormContextMixin:
|
|
"""Shared context for the shipment create and edit forms.
|
|
|
|
The batch's own status is shown on the form through the batch dropdown's
|
|
label, and the other shipments on the batch are listed here so an operator
|
|
can see a possible duplicate before saving — the form does not block one,
|
|
because a split consignment is legitimate.
|
|
"""
|
|
|
|
template_name = 'ops/shipment_form.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
shipment = getattr(self, 'object', None)
|
|
if shipment is not None and shipment.pk:
|
|
ctx['other_shipments'] = shipment.batch.shipments.exclude(pk=shipment.pk)
|
|
else:
|
|
ctx['other_shipments'] = Shipment.objects.none()
|
|
return ctx
|
|
|
|
|
|
class ShipmentCreateView(ShipmentFormContextMixin, OpsRequiredMixin, CreateView):
|
|
model = Shipment
|
|
form_class = ShipmentForm
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = 'New shipment'
|
|
ctx['submit_label'] = 'Create shipment'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
batch = form.instance.batch
|
|
existing = Shipment.objects.filter(batch=batch).count()
|
|
response = super().form_valid(form)
|
|
messages.success(
|
|
self.request, f"Shipment for {batch.batch_reference} created."
|
|
)
|
|
if existing:
|
|
messages.warning(
|
|
self.request,
|
|
f"{batch.batch_reference} already had {existing} shipment(s). This "
|
|
"was saved as a further consignment — check it is not a duplicate.",
|
|
)
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:shipment_detail', kwargs={'pk': self.object.pk})
|
|
|
|
|
|
class ShipmentUpdateView(ShipmentFormContextMixin, OpsRequiredMixin, UpdateView):
|
|
model = Shipment
|
|
form_class = ShipmentForm
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = (
|
|
f"Edit shipment for {self.object.batch.batch_reference}"
|
|
)
|
|
ctx['submit_label'] = 'Save changes'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
batch = form.instance.batch
|
|
previous_batch_id = (
|
|
Shipment.objects.filter(pk=self.object.pk)
|
|
.values_list('batch_id', flat=True)
|
|
.first()
|
|
)
|
|
others = Shipment.objects.filter(batch=batch).exclude(pk=self.object.pk).count()
|
|
response = super().form_valid(form)
|
|
messages.success(
|
|
self.request, f"Shipment for {batch.batch_reference} updated."
|
|
)
|
|
if others and previous_batch_id != batch.pk:
|
|
messages.warning(
|
|
self.request,
|
|
f"{batch.batch_reference} already had {others} shipment(s). This "
|
|
"was saved as a further consignment — check it is not a duplicate.",
|
|
)
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:shipment_detail', kwargs={'pk': self.object.pk})
|
|
|
|
|
|
class CAPAListView(OpsRequiredMixin, ListView):
|
|
model = CAPARecord
|
|
template_name = 'ops/capa_list.html'
|
|
context_object_name = 'records'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = CAPARecord.objects.select_related('batch').all()
|
|
status = self.request.GET.get('status')
|
|
if status:
|
|
qs = qs.filter(status=status)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['status_choices'] = CAPARecord.Status.choices
|
|
ctx['current_status'] = self.request.GET.get('status', '')
|
|
return ctx
|
|
|
|
|
|
class CAPADetailView(OpsRequiredMixin, DetailView):
|
|
model = CAPARecord
|
|
template_name = 'ops/capa_detail.html'
|
|
context_object_name = 'record'
|
|
|
|
|
|
class DocumentListView(OpsRequiredMixin, ListView):
|
|
model = Document
|
|
template_name = 'ops/document_list.html'
|
|
context_object_name = 'documents'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = Document.objects.select_related('batch', 'uploaded_by').all()
|
|
doc_type = self.request.GET.get('doc_type')
|
|
if doc_type:
|
|
qs = qs.filter(doc_type=doc_type)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['doc_type_choices'] = Document.DocType.choices
|
|
ctx['current_doc_type'] = self.request.GET.get('doc_type', '')
|
|
return ctx
|
|
|
|
|
|
class DocumentDownloadView(OpsRequiredMixin, View):
|
|
"""Serve the stored file behind a document row to operations staff.
|
|
|
|
Operations may fetch any document the ops document list shows it. Note the
|
|
list itself has always shown every row regardless of visibility, so this
|
|
matches the list rather than being stricter than it; the buyer-side view is
|
|
where visibility does the gatekeeping.
|
|
"""
|
|
|
|
def get(self, request, pk):
|
|
doc = get_object_or_404(Document, pk=pk)
|
|
return file_response(doc.file_url, doc.file_name)
|
|
|
|
|
|
class CoADownloadView(OpsRequiredMixin, View):
|
|
"""Serve a certificate of analysis to operations staff, at any status.
|
|
|
|
Unlike the buyer's view, a certificate still awaiting release is exactly what
|
|
a reviewer needs to open, so the status is not part of the gate here.
|
|
"""
|
|
|
|
def get(self, request, pk):
|
|
coa = get_object_or_404(CertificateOfAnalysis, pk=pk)
|
|
return file_response(coa.file_url, coa.file_name)
|
|
|
|
|
|
class ESGListView(OpsRequiredMixin, ListView):
|
|
model = ESGFieldReport
|
|
template_name = 'ops/esg_list.html'
|
|
context_object_name = 'reports'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
return ESGFieldReport.objects.select_related('batch').all()
|
|
|
|
|
|
class QCVerifyListView(OpsRequiredMixin, ListView):
|
|
model = QCResult
|
|
template_name = 'ops/qc_verify_list.html'
|
|
context_object_name = 'results'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = QCResult.objects.select_related('batch', 'entered_by').filter(
|
|
verification_status=QCResult.VerificationStatus.DRAFT
|
|
)
|
|
passed = self.request.GET.get('passed')
|
|
if passed == '1':
|
|
qs = qs.filter(overall_pass=True)
|
|
elif passed == '0':
|
|
qs = qs.filter(overall_pass=False)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['current_passed'] = self.request.GET.get('passed', '')
|
|
return ctx
|
|
|
|
|
|
class QCVerifyDetailView(OpsRequiredMixin, DetailView):
|
|
model = QCResult
|
|
template_name = 'ops/qc_verify_detail.html'
|
|
context_object_name = 'result'
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
from django.contrib import messages
|
|
from apps.compliance.services import create_coa_for_qc
|
|
|
|
qc = self.get_object()
|
|
user = request.user
|
|
if qc.entered_by is not None and qc.entered_by_id == user.id:
|
|
messages.error(request, 'The submitter cannot approve their own QC result (four-eyes control).')
|
|
return redirect(self.request.path)
|
|
|
|
approved = request.POST.get('approved') == '1'
|
|
if approved:
|
|
if not qc.overall_pass:
|
|
messages.error(request, 'A failed QC result cannot be approved; it remains quarantined with an open CAPA.')
|
|
return redirect(self.request.path)
|
|
qc.verification_status = QCResult.VerificationStatus.APPROVED
|
|
qc.verified_by = user
|
|
qc.approved_at = timezone.now()
|
|
qc.save()
|
|
coa = create_coa_for_qc(qc, approved_by=user)
|
|
messages.success(request, f'QC result approved. CoA created (status: {coa.status}).')
|
|
return redirect('ops:coa_review_detail', pk=coa.pk)
|
|
else:
|
|
qc.verification_status = QCResult.VerificationStatus.REJECTED
|
|
qc.verified_by = user
|
|
qc.approved_at = timezone.now()
|
|
qc.save()
|
|
messages.warning(request, 'QC result rejected.')
|
|
return redirect('ops:qc_verify_list')
|
|
|
|
|
|
class COAReviewListView(OpsRequiredMixin, ListView):
|
|
model = CertificateOfAnalysis
|
|
template_name = 'ops/coa_review_list.html'
|
|
context_object_name = 'coas'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = CertificateOfAnalysis.objects.select_related('batch', 'qc_result', 'quality_approved_by').all()
|
|
status = self.request.GET.get('status')
|
|
if status:
|
|
qs = qs.filter(status=status)
|
|
else:
|
|
qs = qs.filter(status=CertificateOfAnalysis.Status.PENDING_APPROVAL)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['status_choices'] = CertificateOfAnalysis.Status.choices
|
|
ctx['current_status'] = self.request.GET.get('status', '')
|
|
return ctx
|
|
|
|
|
|
class COAReviewDetailView(OpsRequiredMixin, DetailView):
|
|
model = CertificateOfAnalysis
|
|
template_name = 'ops/coa_review_detail.html'
|
|
context_object_name = 'coa'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['lab_documents'] = Document.objects.filter(batch=self.object.batch, doc_type=Document.DocType.LAB_REPORT)
|
|
return ctx
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
from django.contrib import messages
|
|
from apps.compliance.services import publish_coa
|
|
|
|
coa = self.get_object()
|
|
if request.POST.get('action') == 'approve':
|
|
try:
|
|
publish_coa(coa, released_by=request.user)
|
|
messages.success(request, 'CoA published to buyers.')
|
|
except ValueError as exc:
|
|
messages.error(request, str(exc))
|
|
return redirect(self.request.path)
|
|
|
|
|
|
class EUDRCaseListView(OpsRequiredMixin, ListView):
|
|
model = DueDiligenceCase
|
|
template_name = 'ops/eudr_list.html'
|
|
context_object_name = 'cases'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = DueDiligenceCase.objects.select_related('batch', 'approved_by').all()
|
|
status = self.request.GET.get('status')
|
|
if status:
|
|
qs = qs.filter(status=status)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['status_choices'] = DueDiligenceCase.Status.choices
|
|
ctx['current_status'] = self.request.GET.get('status', '')
|
|
return ctx
|
|
|
|
|
|
class EUDRCaseDetailView(OpsRequiredMixin, DetailView):
|
|
model = DueDiligenceCase
|
|
template_name = 'ops/eudr_detail.html'
|
|
context_object_name = 'case'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
case = self.object
|
|
ctx['lineage'] = list(case.batch.intake_lineage.select_related(
|
|
'harvest_intake', 'harvest_intake__origin_zone', 'harvest_intake__collector_group'
|
|
))
|
|
ctx['evidence'] = case.evidence.all()
|
|
ctx['risk_assessments'] = case.risk_assessments.all()
|
|
ctx['mitigation_actions'] = case.mitigation_actions.all()
|
|
ctx['statement_references'] = case.statement_references.all()
|
|
return ctx
|
|
|
|
def post(self, request, *args, **kwargs):
|
|
from apps.compliance import services
|
|
|
|
case = self.get_object()
|
|
action = request.POST.get('action')
|
|
try:
|
|
if action == 'check':
|
|
services.run_completeness_check(case)
|
|
messages.info(request, 'Completeness check completed.')
|
|
elif action == 'assess_risk':
|
|
services.evaluate_risk(case, user=request.user)
|
|
messages.info(request, 'Risk assessment recorded.')
|
|
elif action == 'approve':
|
|
services.approve_case(case, request.user)
|
|
messages.success(request, 'Case approved.')
|
|
elif action == 'reject':
|
|
services.reject_case(case, request.user, request.POST.get('reason', ''))
|
|
messages.warning(request, 'Case rejected.')
|
|
elif action == 'record_submission':
|
|
ref = request.POST.get('dds_reference')
|
|
if not ref:
|
|
messages.error(request, 'DDS reference required.')
|
|
else:
|
|
services.record_submission(case, request.user, ref, request.POST.get('submission_date') or None)
|
|
messages.success(request, 'External submission recorded.')
|
|
except ValueError as exc:
|
|
messages.error(request, str(exc))
|
|
return redirect(self.request.path)
|
|
|
|
|
|
class IntakeListView(OpsRequiredMixin, ListView):
|
|
"""Harvest intakes, the first record the business actually creates itself."""
|
|
|
|
model = HarvestIntake
|
|
template_name = 'ops/intake_list.html'
|
|
context_object_name = 'intakes'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = HarvestIntake.objects.select_related(
|
|
'collector_group', 'origin_zone'
|
|
).order_by('-intake_date', '-intake_reference')
|
|
status = self.request.GET.get('status')
|
|
q = self.request.GET.get('q')
|
|
if status:
|
|
qs = qs.filter(status=status)
|
|
if q:
|
|
qs = qs.filter(intake_reference__icontains=q)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['status_choices'] = HarvestIntake.Status.choices
|
|
ctx['current_status'] = self.request.GET.get('status', '')
|
|
ctx['current_q'] = self.request.GET.get('q', '')
|
|
return ctx
|
|
|
|
|
|
class IntakeFormContextMixin:
|
|
"""Shared context for the create and edit forms."""
|
|
|
|
template_name = 'ops/intake_form.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
# Which zone each collector group draws from, so the form can fill the
|
|
# zone in when a group is chosen. Kept in a data attribute rather than
|
|
# injected into a script block.
|
|
ctx['group_zones'] = json.dumps({
|
|
str(group.pk): str(group.origin_zone_id)
|
|
for group in CollectorGroup.objects.exclude(origin_zone__isnull=True)
|
|
})
|
|
return ctx
|
|
|
|
|
|
class IntakeCreateView(IntakeFormContextMixin, OpsRequiredMixin, CreateView):
|
|
model = HarvestIntake
|
|
form_class = HarvestIntakeForm
|
|
|
|
def get_initial(self):
|
|
initial = super().get_initial()
|
|
initial['intake_reference'] = suggested_intake_reference()
|
|
initial['intake_date'] = timezone.localdate()
|
|
initial['status'] = HarvestIntake.Status.RECEIVED
|
|
officer = self.request.user.full_name or self.request.user.email
|
|
if officer:
|
|
initial['receiving_officer'] = officer
|
|
return initial
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = 'New harvest intake'
|
|
ctx['submit_label'] = 'Record intake'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
messages.success(
|
|
self.request, f"Intake {self.object.intake_reference} recorded."
|
|
)
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:intake_list')
|
|
|
|
|
|
class IntakeUpdateView(IntakeFormContextMixin, OpsRequiredMixin, UpdateView):
|
|
model = HarvestIntake
|
|
form_class = HarvestIntakeForm
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = f"Correct intake {self.object.intake_reference}"
|
|
ctx['submit_label'] = 'Save changes'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
messages.success(
|
|
self.request, f"Intake {self.object.intake_reference} updated."
|
|
)
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:intake_list')
|
|
|
|
|
|
class GuideIndexView(OpsRequiredMixin, BaseGuideIndexView):
|
|
template_name = 'guides/index_ops.html'
|
|
|
|
|
|
class GuideDetailView(OpsRequiredMixin, BaseGuideDetailView):
|
|
template_name = 'guides/detail_ops.html'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Compliance registers
|
|
#
|
|
# Facility, origin zone and collector group used to be editable only in the
|
|
# Django administration area, which left the platform unable to register a new
|
|
# supply site, zone or group from the portal. These screens close that gap. The
|
|
# admin registrations stay: the portal and the admin are two write paths to the
|
|
# same records, and the forms here mirror the admin's fields.
|
|
|
|
|
|
class FacilityListView(OpsRequiredMixin, ListView):
|
|
model = Facility
|
|
template_name = 'ops/facility_list.html'
|
|
context_object_name = 'facilities'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = Facility.objects.all().order_by('name')
|
|
q = self.request.GET.get('q')
|
|
active = self.request.GET.get('active')
|
|
if q:
|
|
qs = qs.filter(name__icontains=q)
|
|
if active == '1':
|
|
qs = qs.filter(is_active=True)
|
|
elif active == '0':
|
|
qs = qs.filter(is_active=False)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['current_q'] = self.request.GET.get('q', '')
|
|
ctx['current_active'] = self.request.GET.get('active', '')
|
|
return ctx
|
|
|
|
|
|
class FacilityFormContextMixin:
|
|
model = Facility
|
|
form_class = FacilityForm
|
|
template_name = 'ops/register_form.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['cancel_url'] = reverse('ops:facility_list')
|
|
return ctx
|
|
|
|
|
|
class FacilityCreateView(FacilityFormContextMixin, OpsRequiredMixin, CreateView):
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = 'New facility'
|
|
ctx['submit_label'] = 'Create facility'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
messages.success(self.request, f"Facility {self.object.name} created.")
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:facility_list')
|
|
|
|
|
|
class FacilityUpdateView(FacilityFormContextMixin, OpsRequiredMixin, UpdateView):
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = f"Edit facility {self.object.name}"
|
|
ctx['submit_label'] = 'Save changes'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
messages.success(self.request, f"Facility {self.object.name} updated.")
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:facility_list')
|
|
|
|
|
|
def apply_zone_verification(zone, user, previous_status=None):
|
|
"""Record who verified or rejected a zone, and when.
|
|
|
|
A zone moved back to Unverified has its stamp cleared, so the record and the
|
|
audit agree. An unchanged status keeps the original verifier and date — a
|
|
later edit of an unrelated field must not silently re-attribute the check.
|
|
"""
|
|
if zone.verification_status == OriginZone.VerificationStatus.UNVERIFIED:
|
|
zone.verified_by = None
|
|
zone.verified_at = None
|
|
elif previous_status != zone.verification_status or zone.verified_at is None:
|
|
zone.verified_by = user
|
|
zone.verified_at = timezone.now()
|
|
|
|
|
|
class OriginZoneListView(OpsRequiredMixin, ListView):
|
|
model = OriginZone
|
|
template_name = 'ops/origin_zone_list.html'
|
|
context_object_name = 'zones'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = OriginZone.objects.select_related('facility').order_by('zone_code')
|
|
q = self.request.GET.get('q')
|
|
status = self.request.GET.get('status')
|
|
if q:
|
|
qs = qs.filter(zone_code__icontains=q)
|
|
if status:
|
|
qs = qs.filter(verification_status=status)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['status_choices'] = OriginZone.VerificationStatus.choices
|
|
ctx['current_q'] = self.request.GET.get('q', '')
|
|
ctx['current_status'] = self.request.GET.get('status', '')
|
|
return ctx
|
|
|
|
|
|
class OriginZoneFormContextMixin:
|
|
model = OriginZone
|
|
form_class = OriginZoneForm
|
|
template_name = 'ops/register_form.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['cancel_url'] = reverse('ops:origin_zone_list')
|
|
ctx['show_zone_map'] = True
|
|
ctx['tile_url'] = browser_tile_url()
|
|
zone = getattr(self, 'object', None)
|
|
geometry = getattr(zone, 'geometry', None) if zone is not None else None
|
|
ctx['zone_has_geometry'] = bool(geometry and geometry.get('coordinates'))
|
|
ctx['zone_geometry_json'] = json.dumps(geometry) if ctx['zone_has_geometry'] else ''
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
apply_zone_verification(
|
|
form.instance,
|
|
self.request.user,
|
|
previous_status=form.initial.get('verification_status'),
|
|
)
|
|
return super().form_valid(form)
|
|
|
|
|
|
class OriginZoneCreateView(OriginZoneFormContextMixin, OpsRequiredMixin, CreateView):
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = 'New origin zone'
|
|
ctx['submit_label'] = 'Create zone'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
messages.success(
|
|
self.request,
|
|
f"Origin zone {self.object.zone_code} created. "
|
|
"The map below is a view of the stored polygon, not a check of it.",
|
|
)
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:origin_zone_update', kwargs={'pk': self.object.pk})
|
|
|
|
|
|
class OriginZoneUpdateView(OriginZoneFormContextMixin, OpsRequiredMixin, UpdateView):
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = f"Edit origin zone {self.object.zone_code}"
|
|
ctx['submit_label'] = 'Save changes'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
messages.success(
|
|
self.request, f"Origin zone {self.object.zone_code} updated."
|
|
)
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:origin_zone_update', kwargs={'pk': self.object.pk})
|
|
|
|
|
|
class CollectorGroupListView(OpsRequiredMixin, ListView):
|
|
model = CollectorGroup
|
|
template_name = 'ops/collector_group_list.html'
|
|
context_object_name = 'groups'
|
|
paginate_by = 25
|
|
|
|
def get_queryset(self):
|
|
qs = CollectorGroup.objects.select_related('origin_zone').order_by('name')
|
|
q = self.request.GET.get('q')
|
|
if q:
|
|
qs = qs.filter(name__icontains=q)
|
|
return qs
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['current_q'] = self.request.GET.get('q', '')
|
|
return ctx
|
|
|
|
|
|
class CollectorGroupFormContextMixin:
|
|
model = CollectorGroup
|
|
form_class = CollectorGroupForm
|
|
template_name = 'ops/register_form.html'
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['cancel_url'] = reverse('ops:collector_group_list')
|
|
return ctx
|
|
|
|
|
|
class CollectorGroupCreateView(CollectorGroupFormContextMixin, OpsRequiredMixin, CreateView):
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = 'New collector group'
|
|
ctx['submit_label'] = 'Create group'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
messages.success(self.request, f"Collector group {self.object.name} created.")
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:collector_group_list')
|
|
|
|
|
|
class CollectorGroupUpdateView(CollectorGroupFormContextMixin, OpsRequiredMixin, UpdateView):
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = f"Edit collector group {self.object.name}"
|
|
ctx['submit_label'] = 'Save changes'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
messages.success(self.request, f"Collector group {self.object.name} updated.")
|
|
return response
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:collector_group_list')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Records that belong to a due-diligence case
|
|
#
|
|
# Mitigation actions, EUDR evidence and external-submission references used to
|
|
# be creatable/editable only in admin (or, for evidence, not at all from the
|
|
# portal). These screens are reached from the case detail and return to it.
|
|
|
|
|
|
class CaseChildMixin(OpsRequiredMixin):
|
|
template_name = 'ops/register_form.html'
|
|
|
|
def get_case(self):
|
|
return get_object_or_404(DueDiligenceCase, pk=self.kwargs['case_pk'])
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['case'] = self.get_case()
|
|
ctx['cancel_url'] = reverse(
|
|
'ops:eudr_detail', kwargs={'pk': self.kwargs['case_pk']}
|
|
)
|
|
return ctx
|
|
|
|
def get_success_url(self):
|
|
return reverse('ops:eudr_detail', kwargs={'pk': self.kwargs['case_pk']})
|
|
|
|
|
|
class EUDREvidenceCreateView(CaseChildMixin, CreateView):
|
|
model = EUDREvidence
|
|
form_class = EUDREvidenceForm
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = (
|
|
f"Attach evidence — {self.get_case().batch.batch_reference}"
|
|
)
|
|
ctx['submit_label'] = 'Attach evidence'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
form.instance.case = self.get_case()
|
|
response = super().form_valid(form)
|
|
# Adding evidence changes the evidence completeness result, so re-run it
|
|
# exactly as the API does.
|
|
services.run_completeness_check(self.object.case)
|
|
messages.success(
|
|
self.request,
|
|
"Evidence attached. The case's completeness check has been re-run.",
|
|
)
|
|
return response
|
|
|
|
|
|
class EUDREvidenceUpdateView(CaseChildMixin, UpdateView):
|
|
model = EUDREvidence
|
|
form_class = EUDREvidenceForm
|
|
|
|
def get_queryset(self):
|
|
return EUDREvidence.objects.filter(case_id=self.kwargs['case_pk'])
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = (
|
|
f"Edit evidence — {self.get_case().batch.batch_reference}"
|
|
)
|
|
ctx['submit_label'] = 'Save changes'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
services.run_completeness_check(self.object.case)
|
|
messages.success(self.request, 'Evidence updated.')
|
|
return response
|
|
|
|
|
|
class MitigationActionCreateView(CaseChildMixin, CreateView):
|
|
model = MitigationAction
|
|
form_class = MitigationActionForm
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = (
|
|
f"Add mitigation action — {self.get_case().batch.batch_reference}"
|
|
)
|
|
ctx['submit_label'] = 'Add action'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
form.instance.case = self.get_case()
|
|
response = super().form_valid(form)
|
|
messages.success(self.request, 'Mitigation action added.')
|
|
return response
|
|
|
|
|
|
class MitigationActionUpdateView(CaseChildMixin, UpdateView):
|
|
model = MitigationAction
|
|
form_class = MitigationActionForm
|
|
|
|
def get_queryset(self):
|
|
return MitigationAction.objects.filter(case_id=self.kwargs['case_pk'])
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = (
|
|
f"Edit mitigation action — {self.get_case().batch.batch_reference}"
|
|
)
|
|
ctx['submit_label'] = 'Save changes'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
response = super().form_valid(form)
|
|
messages.success(self.request, 'Mitigation action updated.')
|
|
return response
|
|
|
|
|
|
class StatementReferenceUpdateView(CaseChildMixin, UpdateView):
|
|
model = DueDiligenceStatementReference
|
|
form_class = StatementReferenceForm
|
|
|
|
def get_queryset(self):
|
|
return DueDiligenceStatementReference.objects.filter(
|
|
case_id=self.kwargs['case_pk']
|
|
)
|
|
|
|
def get_context_data(self, **kwargs):
|
|
ctx = super().get_context_data(**kwargs)
|
|
ctx['page_title'] = (
|
|
f"Edit submission reference — {self.get_case().batch.batch_reference}"
|
|
)
|
|
ctx['submit_label'] = 'Save changes'
|
|
return ctx
|
|
|
|
def form_valid(self, form):
|
|
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)
|