Walkthrough step 11 said shipment records came through the API or the admin area, because the portal was read-only. The list and detail pages now carry New shipment and Edit shipment controls, with /ops/shipments/new/ and /ops/shipments/<uuid>/edit/ mirroring the intake and batch screens. The form requires the fields the buyer's page reads — status, vessel, container, ETD and ETA — and refuses a departure after the arrival. The bill of lading and the forwarder stay optional, and the ports keep their model defaults. A batch that already has a shipment is flagged in the dropdown and warned about after saving rather than blocked, because a split consignment is legitimate. The batch dropdown labels each batch with its own journey status, and a card explains that the two statuses are independent. Also fixes a pre-existing defect the new screen would have shared: the native date inputs were rendered in the en-gb d/m/Y format, which the browser discards, so the intake and batch edit forms silently opened with empty dates. They now carry the ISO format a native date input requires.
779 lines
28 KiB
Python
779 lines
28 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.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,
|
|
HarvestIntake,
|
|
IntakeBatchLineage,
|
|
)
|
|
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
|
from apps.guides.views import GuideIndexView as BaseGuideIndexView
|
|
from .forms import BatchForm, BatchLineageFormSet, HarvestIntakeForm, ShipmentForm
|
|
|
|
|
|
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'
|