Harvest intakes could only be recorded in the administration area or through the API, so the first record the business actually creates was entered outside the portal it works in. Add list, create and correct screens under Harvest Intakes. The form is deliberately stricter than the model. HarvestIntake allows almost everything to be blank because records also arrive by API, and the platform copes by flagging the gaps later — the EUDR completeness check has an intake data test whose whole job is to catch a missing date or weight. The form therefore requires every field those checks read: reference, collector group, origin zone, date, weight and grade. Three things it does beyond capturing the fields: it suggests the next intake reference for the year (editable, since it may have to match a weighbridge ticket), fills the origin zone in from the collector group while leaving it editable, and refuses a duplicate reference — including one differing only in case. It queries, rather than blocks, when the chosen zone is not the one the group is registered to, and it marks unverified zones in the dropdown, because geolocation passes only for a verified zone. Correcting amends the record in place: the reference a buyer or auditor quotes does not change.
529 lines
19 KiB
Python
529 lines
19 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
|
|
|
|
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
|
|
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
|
from apps.guides.views import GuideIndexView as BaseGuideIndexView
|
|
from .forms import HarvestIntakeForm
|
|
|
|
|
|
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}"
|
|
|
|
|
|
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)
|
|
return ctx
|
|
|
|
|
|
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 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'
|