ixg_platform/apps/ops/views.py
ixgadmin 5e201b2035 Add a shipment entry screen to the ops portal
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.
2026-09-17 16:03:09 +00:00

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'