ixg_platform/apps/documents/views.py
ixgadmin 08096e168f P99C: compliance automation - CoA auto-generation and EUDR due diligence
- Versioned product specification table (Unrefined Shea Butter v1.0 seed) with
  spec-based QC evaluation replacing hard-coded thresholds
- Structured QC results with test methods and four-eyes verification
  (submitter cannot self-approve)
- reportlab IXG Quality Summary PDF auto-generated on quality approval,
  SHA-256 hashed; original independent lab PDF kept authoritative
- CertificateOfAnalysis version/status/hash/approval/supersede lifecycle;
  commercial release publishes to buyers and triggers notification
- EUDR evidence workflow: Facility, OriginZone, CollectorGroup, HarvestIntake,
  IntakeBatchLineage, DueDiligenceCase, EUDREvidence, RiskAssessment,
  MitigationAction, DueDiligenceStatementReference with rule-based
  completeness check and risk assessment (no AI legal conclusions)
- Ops portal: QC Approvals, CoA Reviews, EUDR Compliance screens
- Buyer portal: /portal/compliance page
- 22 automated tests covering four-eyes, CAPA, PDF+hash, publish/notify,
  EUDR blocks and buyer isolation
2026-08-05 13:58:39 +00:00

114 lines
4.9 KiB
Python

import os
import uuid
from django.conf import settings
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.parsers import MultiPartParser, FormParser
from rest_framework.response import Response
from .models import Document, CertificateOfAnalysis, RegulatoryCert
from .serializers import DocumentSerializer, DocumentUploadSerializer, CertificateOfAnalysisSerializer, RegulatoryCertSerializer
class DocumentViewSet(viewsets.ModelViewSet):
queryset = Document.objects.all()
serializer_class = DocumentSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
qs = Document.objects.all()
if user.role == "buyer":
return qs.filter(visibility__in=["buyer"])
return qs
@action(detail=False, methods=["post"], parser_classes=[MultiPartParser, FormParser])
def upload(self, request):
serializer = DocumentUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
file = serializer.validated_data["file"]
batch_id = serializer.validated_data.get("batch_id")
doc_type = serializer.validated_data["doc_type"]
visibility = serializer.validated_data.get("visibility", "buyer")
file_ext = file.name.split(".")[-1].lower()
allowed = ["pdf", "jpg", "jpeg", "png", "xlsx"]
if file_ext not in allowed:
return Response({"error": f"File type .{file_ext} not allowed"}, status=status.HTTP_400_BAD_REQUEST)
if file.size > 50 * 1024 * 1024:
return Response({"error": "File exceeds 50MB limit"}, status=status.HTTP_400_BAD_REQUEST)
batch_dir = os.path.join(settings.MEDIA_ROOT, "ixg-documents", str(batch_id or "uncategorized"))
os.makedirs(batch_dir, exist_ok=True)
filename = f"{uuid.uuid4()}_{file.name}"
filepath = os.path.join(batch_dir, filename)
with open(filepath, "wb+") as dest:
for chunk in file.chunks():
dest.write(chunk)
doc = Document.objects.create(
batch_id=batch_id,
doc_type=doc_type,
file_url=f"/media/ixg-documents/{batch_id or 'uncategorized'}/{filename}",
file_name=file.name,
file_size_bytes=file.size,
visibility=visibility,
uploaded_by=request.user,
)
return Response(DocumentSerializer(doc).data, status=status.HTTP_201_CREATED)
class CertificateOfAnalysisViewSet(viewsets.ModelViewSet):
queryset = CertificateOfAnalysis.objects.select_related("batch", "qc_result").all()
serializer_class = CertificateOfAnalysisSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
user = self.request.user
qs = CertificateOfAnalysis.objects.select_related("batch", "qc_result").all()
if user.role == "buyer":
return qs.filter(status=CertificateOfAnalysis.Status.PUBLISHED)
return qs
@action(detail=True, methods=["post"])
def approve(self, request, pk=None):
"""Commercial release approval: publishes the CoA and exposes it to buyers."""
from apps.compliance.services import publish_coa
from apps.compliance.permissions import IsOpsOrAdmin
if not IsOpsOrAdmin().has_permission(request, self):
return Response({"detail": "Not permitted"}, status=status.HTTP_403_FORBIDDEN)
coa = self.get_object()
if coa.status != CertificateOfAnalysis.Status.PENDING_APPROVAL:
return Response({"detail": "CoA is not pending approval"}, status=status.HTTP_400_BAD_REQUEST)
publish_coa(coa, released_by=request.user)
return Response(CertificateOfAnalysisSerializer(coa).data)
@action(detail=True, methods=["post"])
def supersede(self, request, pk=None):
from apps.compliance.permissions import IsOpsOrAdmin
from django.utils import timezone
if not IsOpsOrAdmin().has_permission(request, self):
return Response({"detail": "Not permitted"}, status=status.HTTP_403_FORBIDDEN)
coa = self.get_object()
replacement_id = request.data.get("replacement_id")
if not replacement_id:
return Response({"detail": "replacement_id is required"}, status=status.HTTP_400_BAD_REQUEST)
replacement = CertificateOfAnalysis.objects.filter(id=replacement_id).first()
if not replacement:
return Response({"detail": "Replacement CoA not found"}, status=status.HTTP_400_BAD_REQUEST)
coa.status = CertificateOfAnalysis.Status.SUPERSEDED
coa.superseded_at = timezone.now()
coa.superseded_by = replacement
coa.save()
return Response(CertificateOfAnalysisSerializer(coa).data)
class RegulatoryCertViewSet(viewsets.ModelViewSet):
queryset = RegulatoryCert.objects.all()
serializer_class = RegulatoryCertSerializer
permission_classes = [permissions.IsAuthenticated]