Facility, origin zone and collector group could only be registered in the Django admin, which left the portal unable to onboard a new supply site, zone or group. Add list/create/edit screens for all three, and create/edit for mitigation actions, EUDR evidence and external-submission references on a due-diligence case. The origin-zone form keeps the map a view of the stored geometry, not a check of it: the map wording is unchanged and geometry is only required to be valid JSON. Verification is stamped from the acting user when the status moves to verified/rejected, and cleared when it returns to unverified. Nothing is removed from the admin; it stays as the superuser fallback. Adds an nginx /tiles/ route on the ops host so the map imagery loads there too (the tile view is root-mounted but ops remaps / to /ops/).
1175 lines
41 KiB
Python
1175 lines
41 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,
|
|
)
|
|
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,
|
|
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
|