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
This commit is contained in:
ixgadmin 2026-08-05 13:58:39 +00:00
parent 27b7fe7a13
commit 08096e168f
38 changed files with 2891 additions and 19 deletions

0
apps/__init__.py Normal file
View file

View file

@ -11,4 +11,5 @@ urlpatterns = [
path('batches/<uuid:pk>/', views.BatchDetailView.as_view(), name='batch_detail'),
path('documents/', views.DocumentListView.as_view(), name='document_list'),
path('subscription/', views.SubscriptionView.as_view(), name='subscription'),
path('compliance/', views.ComplianceView.as_view(), name='compliance'),
]

View file

@ -115,3 +115,18 @@ class ChatEmbedView(BuyerRequiredMixin, TemplateView):
response = super().dispatch(request, *args, **kwargs)
response["X-Frame-Options"] = "ALLOWALL"
return response
class ComplianceView(BuyerRequiredMixin, TemplateView):
template_name = "portal/compliance.html"
def get_context_data(self, **kwargs):
from apps.compliance.models import DueDiligenceCase
from apps.documents.models import CertificateOfAnalysis
ctx = super().get_context_data(**kwargs)
ctx["approved_coas"] = CertificateOfAnalysis.objects.filter(
status=CertificateOfAnalysis.Status.PUBLISHED
).select_related("batch", "quality_approved_by").order_by("-published_at")
ctx["eudr_cases"] = DueDiligenceCase.objects.select_related("batch", "approved_by").order_by("-updated_at")
return ctx

View file

73
apps/compliance/admin.py Normal file
View file

@ -0,0 +1,73 @@
from django.contrib import admin
from .models import (
ProductSpecification, SpecParameter,
Facility, OriginZone, CollectorGroup, HarvestIntake, IntakeBatchLineage,
DueDiligenceCase, EUDREvidence, RiskAssessment, MitigationAction,
DueDiligenceStatementReference,
)
class SpecParameterInline(admin.TabularInline):
model = SpecParameter
extra = 0
@admin.register(ProductSpecification)
class ProductSpecificationAdmin(admin.ModelAdmin):
list_display = ["product", "version", "status", "is_current", "effective_from", "effective_to", "approved_at"]
list_filter = ["status", "is_current", "product"]
inlines = [SpecParameterInline]
@admin.register(Facility)
class FacilityAdmin(admin.ModelAdmin):
list_display = ["name", "country", "region", "facility_type", "is_active"]
@admin.register(OriginZone)
class OriginZoneAdmin(admin.ModelAdmin):
list_display = ["zone_code", "facility", "country", "region", "verification_status", "verified_at"]
list_filter = ["verification_status", "country"]
@admin.register(CollectorGroup)
class CollectorGroupAdmin(admin.ModelAdmin):
list_display = ["name", "contact_reference", "origin_zone"]
@admin.register(HarvestIntake)
class HarvestIntakeAdmin(admin.ModelAdmin):
list_display = ["intake_reference", "intake_date", "weight_kg", "grade", "origin_zone", "status"]
list_filter = ["status"]
@admin.register(IntakeBatchLineage)
class IntakeBatchLineageAdmin(admin.ModelAdmin):
list_display = ["batch", "harvest_intake", "weight_allocated_kg"]
@admin.register(DueDiligenceCase)
class DueDiligenceCaseAdmin(admin.ModelAdmin):
list_display = ["batch", "status", "risk_level", "approved_at", "updated_at"]
list_filter = ["status", "risk_level"]
@admin.register(EUDREvidence)
class EUDREvidenceAdmin(admin.ModelAdmin):
list_display = ["case", "evidence_type", "source", "collected_at", "verified_at"]
@admin.register(RiskAssessment)
class RiskAssessmentAdmin(admin.ModelAdmin):
list_display = ["case", "risk_level", "assessed_by", "assessed_at"]
@admin.register(MitigationAction)
class MitigationActionAdmin(admin.ModelAdmin):
list_display = ["case", "action", "status", "owner", "due_date"]
@admin.register(DueDiligenceStatementReference)
class DueDiligenceStatementReferenceAdmin(admin.ModelAdmin):
list_display = ["case", "dds_reference", "submission_date", "submitted_by", "reconciliation_status"]

7
apps/compliance/apps.py Normal file
View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class ComplianceConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.compliance"
verbose_name = "Compliance"

124
apps/compliance/coa_pdf.py Normal file
View file

@ -0,0 +1,124 @@
import hashlib
from datetime import date
from io import BytesIO
from django.utils import timezone
def generate_coa_pdf(qc, batch, spec_version="", issuer="IXG Ltd"):
"""Generate the IXG Quality Summary cover sheet PDF for an approved QC result.
This is a companion to (never a replacement for) the independent laboratory
certificate, which remains the authoritative source document.
Returns (pdf_bytes, sha256_hex).
"""
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import mm
from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
"TitleX", parent=styles["Title"], fontSize=16, leading=20,
textColor=colors.HexColor("#1A3D2B"),
)
sub_style = ParagraphStyle(
"SubX", parent=styles["Normal"], fontSize=9, leading=12,
textColor=colors.HexColor("#4B5563"),
)
cell_style = ParagraphStyle("CellX", parent=styles["Normal"], fontSize=9, leading=11)
issued = date.today()
reference = f"{batch.batch_reference}-COA-V{1}"
rows = [
["Batch reference", batch.batch_reference],
["Product", "Unrefined Shea Butter"],
["Origin", batch.origin_facility],
["Grade", batch.grade or "-"],
["Weight", f"{batch.weight_kg} kg" if batch.weight_kg is not None else "-"],
["Harvest date", batch.harvest_date.isoformat() if batch.harvest_date else "-"],
["Specification", spec_version or "-"],
["Lab reference", qc.lab_reference or "-"],
["Tested by", qc.tested_by or "-"],
["Quality approved by", (qc.verified_by.full_name or qc.verified_by.email) if qc.verified_by else "-"],
["Quality approved on", qc.approved_at.isoformat() if qc.approved_at else "-"],
["Issued by", issuer],
["Issue date", issued.isoformat()],
]
body = [
["Parameter", "Result", "Unit", "Result status"],
["FFA", f"{qc.ffa_percentage}", "%", "Within specification" if float(qc.ffa_percentage) <= 3 else "Outside specification"],
["Moisture", f"{qc.moisture_percentage}", "%", "Within specification" if float(qc.moisture_percentage) <= 0.1 else "Outside specification"],
["Peroxide value", f"{qc.peroxide_value}", "meq O2/kg", "Within specification" if float(qc.peroxide_value) <= 10 else "Outside specification"],
["Visual grade", qc.visual_grade, "-", "Within specification" if qc.visual_grade != "Fail" else "Outside specification"],
]
pdf = BytesIO()
doc = SimpleDocTemplate(pdf, pagesize=A4, leftMargin=20 * mm, rightMargin=20 * mm, topMargin=18 * mm, bottomMargin=18 * mm)
elements = []
elements.append(Paragraph("IXG Ltd &mdash; Quality Summary", title_style))
elements.append(Spacer(1, 2 * mm))
elements.append(Paragraph(
"Companion document to the independent laboratory Certificate of Analysis. "
"The independent laboratory certificate remains the authoritative source document.",
sub_style,
))
elements.append(Spacer(1, 6 * mm))
header_table = Table([[Paragraph(f"Reference: {reference}", sub_style)]], colWidths=[120 * mm])
elements.append(header_table)
elements.append(Spacer(1, 4 * mm))
meta = Table([[Paragraph(c, cell_style) for c in row] for row in rows], colWidths=[55 * mm, 115 * mm])
meta.setStyle(TableStyle([
("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#EAF3EF")),
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#CCD3CF")),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
elements.append(meta)
elements.append(Spacer(1, 6 * mm))
elements.append(Paragraph("Results vs Specification", styles["Heading3"]))
elements.append(Spacer(1, 2 * mm))
result_table = Table(body, colWidths=[55 * mm, 35 * mm, 30 * mm, 50 * mm], repeatRows=1)
result_table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1A3D2B")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#CCD3CF")),
("FONTSIZE", (0, 0), (-1, -1), 9),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F7F6F2")]),
]))
elements.append(result_table)
elements.append(Spacer(1, 6 * mm))
elements.append(Paragraph(
"This summary is derived from the structured QC record on the IXG platform. "
"The independent laboratory certificate remains the authoritative source document.",
sub_style,
))
doc.build(elements)
data = pdf.getvalue()
digest = hashlib.sha256(data).hexdigest()
return data, digest
def write_coa_file(qc, batch, pdf_bytes):
"""Persist the generated PDF under media/ixg-documents/{batch_id}/ and return (file_url, file_name)."""
import os
import uuid as uuid_mod
from django.conf import settings
batch_dir = os.path.join(settings.MEDIA_ROOT, "ixg-documents", str(batch.id))
os.makedirs(batch_dir, exist_ok=True)
filename = f"coa-{uuid_mod.uuid4().hex[:8]}-{batch.batch_reference}.pdf"
filepath = os.path.join(batch_dir, filename)
with open(filepath, "wb") as f:
f.write(pdf_bytes)
return f"/media/ixg-documents/{batch.id}/{filename}", filename

View file

@ -0,0 +1,235 @@
# Generated by Django 5.2.12 on 2026-08-05 13:36
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('batches', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CollectorGroup',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('contact_reference', models.CharField(blank=True, max_length=255)),
('notes', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'collector_groups',
},
),
migrations.CreateModel(
name='Facility',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('country', models.CharField(max_length=100)),
('region', models.CharField(blank=True, max_length=100)),
('address', models.TextField(blank=True)),
('facility_type', models.CharField(choices=[('production', 'Production'), ('processing', 'Processing')], default='production', max_length=20)),
('is_active', models.BooleanField(default=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
],
options={
'db_table': 'facilities',
},
),
migrations.CreateModel(
name='DueDiligenceCase',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('status', models.CharField(choices=[('incomplete', 'Incomplete'), ('complete_for_review', 'Complete for Review'), ('risk_identified', 'Risk Identified'), ('mitigation_required', 'Mitigation Required'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='incomplete', max_length=30)),
('risk_level', models.CharField(choices=[('low', 'Low'), ('standard', 'Standard'), ('high', 'High')], default='standard', max_length=20)),
('completeness_results', models.JSONField(blank=True, default=dict)),
('assessed_at', models.DateTimeField(blank=True, null=True)),
('approved_at', models.DateTimeField(blank=True, null=True)),
('rejected_reason', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='eudr_cases_approved', to=settings.AUTH_USER_MODEL)),
('assessed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='eudr_cases_assessed', to=settings.AUTH_USER_MODEL)),
('batch', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='due_diligence_case', to='batches.processingbatch')),
],
options={
'db_table': 'due_diligence_cases',
},
),
migrations.CreateModel(
name='DueDiligenceStatementReference',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('dds_reference', models.CharField(blank=True, max_length=255)),
('submission_date', models.DateField(blank=True, null=True)),
('reconciliation_status', models.CharField(choices=[('pending', 'Pending'), ('reconciled', 'Reconciled')], default='pending', max_length=20)),
('notes', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='statement_references', to='compliance.duediligencecase')),
('submitted_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dds_submissions', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'due_diligence_statement_references',
},
),
migrations.CreateModel(
name='EUDREvidence',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('evidence_type', models.CharField(choices=[('geolocation', 'Geolocation'), ('harvest_document', 'Harvest Document'), ('supplier_identity', 'Supplier Identity'), ('legality', 'Legality'), ('other', 'Other')], default='other', max_length=30)),
('file', models.CharField(max_length=255)),
('source', models.CharField(blank=True, max_length=255)),
('collected_at', models.DateField(blank=True, null=True)),
('verified_at', models.DateTimeField(blank=True, null=True)),
('notes', models.TextField(blank=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='evidence', to='compliance.duediligencecase')),
('verified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='eudr_evidence_verified', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'eudr_evidence',
},
),
migrations.CreateModel(
name='MitigationAction',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('action', models.TextField()),
('status', models.CharField(choices=[('open', 'Open'), ('in_progress', 'In Progress'), ('completed', 'Completed'), ('verified', 'Verified')], default='open', max_length=20)),
('owner', models.CharField(blank=True, max_length=255)),
('due_date', models.DateField(blank=True, null=True)),
('evidence_file', models.CharField(blank=True, max_length=255)),
('completed_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='mitigation_actions', to='compliance.duediligencecase')),
],
options={
'db_table': 'mitigation_actions',
},
),
migrations.CreateModel(
name='OriginZone',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('zone_code', models.CharField(max_length=100, unique=True)),
('country', models.CharField(max_length=100)),
('region', models.CharField(blank=True, max_length=100)),
('geometry', models.JSONField(blank=True, default=dict)),
('coordinate_source', models.CharField(blank=True, max_length=255)),
('coordinates_collected_by', models.CharField(blank=True, max_length=255)),
('coordinates_collected_at', models.DateField(blank=True, null=True)),
('evidence_file', models.CharField(blank=True, max_length=255)),
('verification_status', models.CharField(choices=[('unverified', 'Unverified'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='unverified', max_length=20)),
('verified_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('facility', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='origin_zones', to='compliance.facility')),
('verified_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='zones_verified', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'origin_zones',
},
),
migrations.CreateModel(
name='HarvestIntake',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('intake_reference', models.CharField(max_length=100, unique=True)),
('intake_date', models.DateField(blank=True, null=True)),
('weight_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('grade', models.CharField(blank=True, max_length=5)),
('receiving_officer', models.CharField(blank=True, max_length=255)),
('status', models.CharField(choices=[('draft', 'Draft'), ('received', 'Received'), ('verified', 'Verified'), ('rejected', 'Rejected')], default='draft', max_length=20)),
('evidence_file', models.CharField(blank=True, max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('collector_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='intakes', to='compliance.collectorgroup')),
('origin_zone', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='intakes', to='compliance.originzone')),
],
options={
'db_table': 'harvest_intakes',
},
),
migrations.AddField(
model_name='collectorgroup',
name='origin_zone',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='collector_groups', to='compliance.originzone'),
),
migrations.CreateModel(
name='ProductSpecification',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('product', models.CharField(max_length=100)),
('version', models.CharField(max_length=20)),
('status', models.CharField(choices=[('draft', 'Draft'), ('approved', 'Approved'), ('superseded', 'Superseded')], default='draft', max_length=20)),
('effective_from', models.DateField(blank=True, null=True)),
('effective_to', models.DateField(blank=True, null=True)),
('is_current', models.BooleanField(default=False)),
('approved_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('approved_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='specs_approved', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'product_specifications',
'ordering': ['product', '-version'],
'unique_together': {('product', 'version')},
},
),
migrations.CreateModel(
name='RiskAssessment',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('risk_level', models.CharField(choices=[('low', 'Low'), ('standard', 'Standard'), ('high', 'High')], default='standard', max_length=20)),
('basis', models.TextField(blank=True)),
('findings', models.TextField(blank=True)),
('assessed_at', models.DateTimeField(auto_now_add=True)),
('assessed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='risk_assessments', to=settings.AUTH_USER_MODEL)),
('case', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='risk_assessments', to='compliance.duediligencecase')),
],
options={
'db_table': 'risk_assessments',
'ordering': ['-assessed_at'],
},
),
migrations.CreateModel(
name='IntakeBatchLineage',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('weight_allocated_kg', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('batch', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='intake_lineage', to='batches.processingbatch')),
('harvest_intake', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='batch_lineage', to='compliance.harvestintake')),
],
options={
'db_table': 'intake_batch_lineage',
'unique_together': {('batch', 'harvest_intake')},
},
),
migrations.CreateModel(
name='SpecParameter',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('parameter', models.CharField(choices=[('ffa', 'Free Fatty Acid'), ('moisture', 'Moisture'), ('peroxide', 'Peroxide Value'), ('visual', 'Visual Grade')], max_length=20)),
('unit', models.CharField(blank=True, max_length=50)),
('min_value', models.DecimalField(blank=True, decimal_places=3, max_digits=8, null=True)),
('max_value', models.DecimalField(blank=True, decimal_places=3, max_digits=8, null=True)),
('allowed_values', models.CharField(blank=True, max_length=255)),
('required', models.BooleanField(default=True)),
('sort_order', models.IntegerField(default=0)),
('spec', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='parameters', to='compliance.productspecification')),
],
options={
'db_table': 'spec_parameters',
'ordering': ['sort_order'],
'unique_together': {('spec', 'parameter')},
},
),
]

View file

@ -0,0 +1,49 @@
from django.db import migrations
def seed_default_spec(apps, schema_editor):
ProductSpecification = apps.get_model("compliance", "ProductSpecification")
SpecParameter = apps.get_model("compliance", "SpecParameter")
spec, created = ProductSpecification.objects.get_or_create(
product="Unrefined Shea Butter",
version="1.0",
defaults={
"status": "approved",
"is_current": True,
},
)
if created:
parameters = [
("ffa", "%", None, "3.000", "", 1),
("moisture", "%", None, "0.100", "", 2),
("peroxide", "meq O2/kg", None, "10.000", "", 3),
("visual", "grade", None, None, "A,B,C", 4),
]
for parameter, unit, min_value, max_value, allowed, sort_order in parameters:
SpecParameter.objects.create(
spec=spec,
parameter=parameter,
unit=unit,
min_value=min_value,
max_value=max_value,
allowed_values=allowed,
required=True,
sort_order=sort_order,
)
def remove_default_spec(apps, schema_editor):
ProductSpecification = apps.get_model("compliance", "ProductSpecification")
ProductSpecification.objects.filter(product="Unrefined Shea Butter", version="1.0").delete()
class Migration(migrations.Migration):
dependencies = [
("compliance", "0001_initial"),
]
operations = [
migrations.RunPython(seed_default_spec, remove_default_spec),
]

View file

335
apps/compliance/models.py Normal file
View file

@ -0,0 +1,335 @@
import uuid
from django.conf import settings
from django.db import models
class ProductSpecification(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
APPROVED = "approved", "Approved"
SUPERSEDED = "superseded", "Superseded"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
product = models.CharField(max_length=100)
version = models.CharField(max_length=20)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
effective_from = models.DateField(null=True, blank=True)
effective_to = models.DateField(null=True, blank=True)
is_current = models.BooleanField(default=False)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True,
related_name="specs_approved",
)
approved_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "product_specifications"
unique_together = [("product", "version")]
ordering = ["product", "-version"]
def __str__(self):
return f"{self.product} v{self.version} ({self.status})"
@classmethod
def current(cls, product):
return cls.objects.filter(product=product, status=cls.Status.APPROVED, is_current=True).first()
class SpecParameter(models.Model):
class Parameter(models.TextChoices):
FFA = "ffa", "Free Fatty Acid"
MOISTURE = "moisture", "Moisture"
PEROXIDE = "peroxide", "Peroxide Value"
VISUAL = "visual", "Visual Grade"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
spec = models.ForeignKey(ProductSpecification, on_delete=models.CASCADE, related_name="parameters")
parameter = models.CharField(max_length=20, choices=Parameter.choices)
unit = models.CharField(max_length=50, blank=True)
min_value = models.DecimalField(max_digits=8, decimal_places=3, null=True, blank=True)
max_value = models.DecimalField(max_digits=8, decimal_places=3, null=True, blank=True)
allowed_values = models.CharField(max_length=255, blank=True)
required = models.BooleanField(default=True)
sort_order = models.IntegerField(default=0)
class Meta:
db_table = "spec_parameters"
unique_together = [("spec", "parameter")]
ordering = ["sort_order"]
def __str__(self):
return f"{self.get_parameter_display()} ({self.unit})"
def check_value(self, value):
if value is None or value == "":
if self.required:
return ("missing", "Value required")
return ("within", "Optional value absent")
if self.parameter == self.Parameter.VISUAL:
allowed = [v.strip() for v in self.allowed_values.split(",") if v.strip()]
if allowed and str(value) not in allowed:
return ("outside", f"Visual grade '{value}' not in allowed values: {', '.join(allowed)}")
return ("within", "Within specification")
try:
numeric = float(value)
except (TypeError, ValueError):
return ("missing", "Non-numeric value")
if self.min_value is not None and numeric < float(self.min_value):
return ("outside", f"{value} below minimum {self.min_value} {self.unit}")
if self.max_value is not None and numeric > float(self.max_value):
return ("outside", f"{value} above maximum {self.max_value} {self.unit}")
return ("within", "Within specification")
class Facility(models.Model):
class FacilityType(models.TextChoices):
PRODUCTION = "production", "Production"
PROCESSING = "processing", "Processing"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255)
country = models.CharField(max_length=100)
region = models.CharField(max_length=100, blank=True)
address = models.TextField(blank=True)
facility_type = models.CharField(max_length=20, choices=FacilityType.choices, default=FacilityType.PRODUCTION)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "facilities"
def __str__(self):
return self.name
class OriginZone(models.Model):
class VerificationStatus(models.TextChoices):
UNVERIFIED = "unverified", "Unverified"
VERIFIED = "verified", "Verified"
REJECTED = "rejected", "Rejected"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
facility = models.ForeignKey(Facility, on_delete=models.CASCADE, related_name="origin_zones")
zone_code = models.CharField(max_length=100, unique=True)
country = models.CharField(max_length=100)
region = models.CharField(max_length=100, blank=True)
geometry = models.JSONField(default=dict, blank=True)
coordinate_source = models.CharField(max_length=255, blank=True)
coordinates_collected_by = models.CharField(max_length=255, blank=True)
coordinates_collected_at = models.DateField(null=True, blank=True)
evidence_file = models.CharField(max_length=255, blank=True)
verification_status = models.CharField(
max_length=20, choices=VerificationStatus.choices, default=VerificationStatus.UNVERIFIED
)
verified_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="zones_verified"
)
verified_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "origin_zones"
def __str__(self):
return f"{self.zone_code} ({self.country})"
class CollectorGroup(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
name = models.CharField(max_length=255)
contact_reference = models.CharField(max_length=255, blank=True)
origin_zone = models.ForeignKey(
OriginZone, on_delete=models.SET_NULL, null=True, blank=True, related_name="collector_groups"
)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "collector_groups"
def __str__(self):
return self.name
class HarvestIntake(models.Model):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
RECEIVED = "received", "Received"
VERIFIED = "verified", "Verified"
REJECTED = "rejected", "Rejected"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
intake_reference = models.CharField(max_length=100, unique=True)
collector_group = models.ForeignKey(
CollectorGroup, on_delete=models.SET_NULL, null=True, blank=True, related_name="intakes"
)
origin_zone = models.ForeignKey(
OriginZone, on_delete=models.SET_NULL, null=True, blank=True, related_name="intakes"
)
intake_date = models.DateField(null=True, blank=True)
weight_kg = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
grade = models.CharField(max_length=5, blank=True)
receiving_officer = models.CharField(max_length=255, blank=True)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
evidence_file = models.CharField(max_length=255, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "harvest_intakes"
def __str__(self):
return self.intake_reference
class IntakeBatchLineage(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
batch = models.ForeignKey("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="intake_lineage")
harvest_intake = models.ForeignKey(HarvestIntake, on_delete=models.CASCADE, related_name="batch_lineage")
weight_allocated_kg = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "intake_batch_lineage"
unique_together = [("batch", "harvest_intake")]
def __str__(self):
return f"{self.batch.batch_reference} <- {self.harvest_intake.intake_reference}"
class DueDiligenceCase(models.Model):
class Status(models.TextChoices):
INCOMPLETE = "incomplete", "Incomplete"
COMPLETE_FOR_REVIEW = "complete_for_review", "Complete for Review"
RISK_IDENTIFIED = "risk_identified", "Risk Identified"
MITIGATION_REQUIRED = "mitigation_required", "Mitigation Required"
APPROVED = "approved", "Approved"
REJECTED = "rejected", "Rejected"
class RiskLevel(models.TextChoices):
LOW = "low", "Low"
STANDARD = "standard", "Standard"
HIGH = "high", "High"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
batch = models.OneToOneField("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="due_diligence_case")
status = models.CharField(max_length=30, choices=Status.choices, default=Status.INCOMPLETE)
risk_level = models.CharField(max_length=20, choices=RiskLevel.choices, default=RiskLevel.STANDARD)
completeness_results = models.JSONField(default=dict, blank=True)
assessed_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="eudr_cases_assessed"
)
assessed_at = models.DateTimeField(null=True, blank=True)
approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="eudr_cases_approved"
)
approved_at = models.DateTimeField(null=True, blank=True)
rejected_reason = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "due_diligence_cases"
def __str__(self):
return f"EUDR case - {self.batch.batch_reference} ({self.status})"
class EUDREvidence(models.Model):
class EvidenceType(models.TextChoices):
GEOLOCATION = "geolocation", "Geolocation"
HARVEST_DOCUMENT = "harvest_document", "Harvest Document"
SUPPLIER_IDENTITY = "supplier_identity", "Supplier Identity"
LEGALITY = "legality", "Legality"
OTHER = "other", "Other"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
case = models.ForeignKey(DueDiligenceCase, on_delete=models.CASCADE, related_name="evidence")
evidence_type = models.CharField(max_length=30, choices=EvidenceType.choices, default=EvidenceType.OTHER)
file = models.CharField(max_length=255)
source = models.CharField(max_length=255, blank=True)
collected_at = models.DateField(null=True, blank=True)
verified_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="eudr_evidence_verified"
)
verified_at = models.DateTimeField(null=True, blank=True)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "eudr_evidence"
def __str__(self):
return f"{self.get_evidence_type_display()} - {self.case.batch.batch_reference}"
class RiskAssessment(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
case = models.ForeignKey(DueDiligenceCase, on_delete=models.CASCADE, related_name="risk_assessments")
risk_level = models.CharField(max_length=20, choices=DueDiligenceCase.RiskLevel.choices, default=DueDiligenceCase.RiskLevel.STANDARD)
basis = models.TextField(blank=True)
findings = models.TextField(blank=True)
assessed_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="risk_assessments"
)
assessed_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "risk_assessments"
ordering = ["-assessed_at"]
def __str__(self):
return f"Risk {self.risk_level} - {self.case.batch.batch_reference}"
class MitigationAction(models.Model):
class Status(models.TextChoices):
OPEN = "open", "Open"
IN_PROGRESS = "in_progress", "In Progress"
COMPLETED = "completed", "Completed"
VERIFIED = "verified", "Verified"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
case = models.ForeignKey(DueDiligenceCase, on_delete=models.CASCADE, related_name="mitigation_actions")
action = models.TextField()
status = models.CharField(max_length=20, choices=Status.choices, default=Status.OPEN)
owner = models.CharField(max_length=255, blank=True)
due_date = models.DateField(null=True, blank=True)
evidence_file = models.CharField(max_length=255, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "mitigation_actions"
def __str__(self):
return f"Mitigation - {self.case.batch.batch_reference} ({self.status})"
class DueDiligenceStatementReference(models.Model):
class ReconciliationStatus(models.TextChoices):
PENDING = "pending", "Pending"
RECONCILED = "reconciled", "Reconciled"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
case = models.ForeignKey(DueDiligenceCase, on_delete=models.CASCADE, related_name="statement_references")
dds_reference = models.CharField(max_length=255, blank=True)
submission_date = models.DateField(null=True, blank=True)
submitted_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="dds_submissions"
)
reconciliation_status = models.CharField(
max_length=20, choices=ReconciliationStatus.choices, default=ReconciliationStatus.PENDING
)
notes = models.TextField(blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "due_diligence_statement_references"
def __str__(self):
return f"DDS {self.dds_reference or '-'} - {self.case.batch.batch_reference}"

View file

@ -0,0 +1,13 @@
from rest_framework import permissions
class IsOpsOrAdmin(permissions.BasePermission):
def has_permission(self, request, view):
user = request.user
return bool(user and user.is_authenticated and (user.role in ("ops", "admin") or user.is_superuser))
class IsAdmin(permissions.BasePermission):
def has_permission(self, request, view):
user = request.user
return bool(user and user.is_authenticated and (user.role == "admin" or user.is_superuser))

View file

@ -0,0 +1,122 @@
from rest_framework import serializers
from .models import (
ProductSpecification, SpecParameter,
Facility, OriginZone, CollectorGroup, HarvestIntake, IntakeBatchLineage,
DueDiligenceCase, EUDREvidence, RiskAssessment, MitigationAction,
DueDiligenceStatementReference,
)
class SpecParameterSerializer(serializers.ModelSerializer):
class Meta:
model = SpecParameter
fields = "__all__"
read_only_fields = ["id"]
class ProductSpecificationSerializer(serializers.ModelSerializer):
parameters = SpecParameterSerializer(many=True, required=False)
approved_by_name = serializers.CharField(source="approved_by.full_name", read_only=True)
class Meta:
model = ProductSpecification
fields = "__all__"
read_only_fields = ["id", "approved_by", "approved_at", "created_at", "updated_at"]
class FacilitySerializer(serializers.ModelSerializer):
class Meta:
model = Facility
fields = "__all__"
read_only_fields = ["id", "created_at"]
class OriginZoneSerializer(serializers.ModelSerializer):
facility_name = serializers.CharField(source="facility.name", read_only=True)
verified_by_name = serializers.CharField(source="verified_by.full_name", read_only=True)
class Meta:
model = OriginZone
fields = "__all__"
read_only_fields = ["id", "verified_by", "verified_at", "created_at"]
class CollectorGroupSerializer(serializers.ModelSerializer):
class Meta:
model = CollectorGroup
fields = "__all__"
read_only_fields = ["id", "created_at"]
class HarvestIntakeSerializer(serializers.ModelSerializer):
origin_zone_code = serializers.CharField(source="origin_zone.zone_code", read_only=True)
collector_group_name = serializers.CharField(source="collector_group.name", read_only=True)
class Meta:
model = HarvestIntake
fields = "__all__"
read_only_fields = ["id", "created_at"]
class IntakeBatchLineageSerializer(serializers.ModelSerializer):
batch_reference = serializers.CharField(source="batch.batch_reference", read_only=True)
intake_reference = serializers.CharField(source="harvest_intake.intake_reference", read_only=True)
class Meta:
model = IntakeBatchLineage
fields = "__all__"
read_only_fields = ["id", "created_at"]
class EUDREvidenceSerializer(serializers.ModelSerializer):
verified_by_name = serializers.CharField(source="verified_by.full_name", read_only=True)
class Meta:
model = EUDREvidence
fields = "__all__"
read_only_fields = ["id", "verified_by", "verified_at", "created_at"]
class RiskAssessmentSerializer(serializers.ModelSerializer):
assessed_by_name = serializers.CharField(source="assessed_by.full_name", read_only=True)
class Meta:
model = RiskAssessment
fields = "__all__"
read_only_fields = ["id", "assessed_at", "assessed_by"]
class MitigationActionSerializer(serializers.ModelSerializer):
class Meta:
model = MitigationAction
fields = "__all__"
read_only_fields = ["id", "created_at"]
class DueDiligenceStatementReferenceSerializer(serializers.ModelSerializer):
submitted_by_name = serializers.CharField(source="submitted_by.full_name", read_only=True)
class Meta:
model = DueDiligenceStatementReference
fields = "__all__"
read_only_fields = ["id", "submitted_by", "created_at"]
class DueDiligenceCaseSerializer(serializers.ModelSerializer):
batch_reference = serializers.CharField(source="batch.batch_reference", read_only=True)
approved_by_name = serializers.CharField(source="approved_by.full_name", read_only=True)
assessed_by_name = serializers.CharField(source="assessed_by.full_name", read_only=True)
evidence = EUDREvidenceSerializer(many=True, read_only=True)
risk_assessments = RiskAssessmentSerializer(many=True, read_only=True)
mitigation_actions = MitigationActionSerializer(many=True, read_only=True)
statement_references = DueDiligenceStatementReferenceSerializer(many=True, read_only=True)
class Meta:
model = DueDiligenceCase
fields = "__all__"
read_only_fields = [
"id", "status", "risk_level", "completeness_results", "assessed_by",
"assessed_at", "approved_by", "approved_at", "rejected_reason",
"created_at", "updated_at",
]

372
apps/compliance/services.py Normal file
View file

@ -0,0 +1,372 @@
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from apps.batches.models import ProcessingBatch
from apps.documents.models import CertificateOfAnalysis, Document
from apps.notifications.models import Notification
from apps.qc.models import QCResult
DEFAULT_PRODUCT = "Unrefined Shea Butter"
# Legacy fallback thresholds used only when no approved product specification exists.
FALLBACK_LIMITS = {
"ffa": {"max": Decimal("3.00")},
"moisture": {"max": Decimal("0.10")},
"peroxide": {"max": Decimal("10.00")},
}
# Sentinel: default behaviour looks up the current approved spec; an explicit
# None forces legacy fallback limits (used by tests and edge cases).
_USE_CURRENT = object()
def evaluate_qc_data(values, spec=_USE_CURRENT):
"""Evaluate a QC result against a specification (or legacy fallback limits).
Returns (overall_pass, result_status, parameter_details, spec_version).
"""
from apps.compliance.models import ProductSpecification, SpecParameter
values = dict(values)
if "visual" not in values and "visual_grade" in values:
values["visual"] = values["visual_grade"]
if spec is _USE_CURRENT:
spec = ProductSpecification.current(DEFAULT_PRODUCT)
spec_version = spec.version if spec else ""
details = {}
outside = False
missing = False
if spec:
for param in spec.parameters.all():
value = values.get(param.parameter)
status, detail = param.check_value(value)
details[param.parameter] = {"status": status, "detail": detail, "unit": param.unit}
if status == "outside":
outside = True
elif status == "missing":
missing = True
else:
for key, limits in FALLBACK_LIMITS.items():
value = values.get(key)
try:
numeric = float(value)
except (TypeError, ValueError):
missing = True
details[key] = {"status": "missing", "detail": "Value missing", "unit": ""}
continue
ok = numeric <= float(limits["max"])
details[key] = {
"status": "within" if ok else "outside",
"detail": "Within specification" if ok else "Above maximum",
"unit": "",
}
if not ok:
outside = True
visual = values.get("visual")
if visual and visual != "Fail":
details["visual"] = {"status": "within", "detail": "Within specification", "unit": ""}
else:
outside = True
details["visual"] = {"status": "outside", "detail": "Visual grade failed", "unit": ""}
if outside:
result_status = QCResult.ResultStatus.OUTSIDE_SPEC
elif missing:
result_status = QCResult.ResultStatus.MISSING
else:
result_status = QCResult.ResultStatus.WITHIN_SPEC
overall_pass = not outside and not missing
return overall_pass, result_status, details, spec_version
def find_source_lab_document(batch):
"""Return the most recent uploaded independent laboratory report for a batch."""
return Document.objects.filter(batch=batch, doc_type=Document.DocType.LAB_REPORT).order_by("-uploaded_at").first()
def create_coa_for_qc(qc, approved_by):
"""Generate the IXG Quality Summary PDF and create the (pending) CoA record.
Only called after a separate quality approval of a passing QC result (four-eyes).
"""
from apps.compliance.coa_pdf import generate_coa_pdf, write_coa_file
if not qc.overall_pass:
raise ValueError("CoA can only be generated for a passing QC result")
if CertificateOfAnalysis.objects.filter(qc_result=qc).exists():
return CertificateOfAnalysis.objects.get(qc_result=qc)
batch = qc.batch
pdf_bytes, digest = generate_coa_pdf(qc, batch, spec_version=qc.spec_version)
file_url, file_name = write_coa_file(qc, batch, pdf_bytes)
source = find_source_lab_document(batch)
return CertificateOfAnalysis.objects.create(
batch=batch,
qc_result=qc,
file_url=file_url,
file_name=file_name,
issuer="IXG Ltd",
issue_date=timezone.localdate(),
uploaded_by=approved_by,
status=CertificateOfAnalysis.Status.PENDING_APPROVAL,
version=1,
sha256_hash=digest,
spec_version=qc.spec_version,
source_document=source,
quality_approved_by=approved_by,
quality_approved_at=timezone.now(),
)
def publish_coa(coa, released_by):
"""Commercial release approval: mark published, expose to buyers, notify buyers."""
if coa.status == CertificateOfAnalysis.Status.PUBLISHED:
return coa
with transaction.atomic():
coa.status = CertificateOfAnalysis.Status.PUBLISHED
coa.released_by = released_by
coa.released_at = timezone.now()
coa.published_at = timezone.now()
coa.save(update_fields=[
"status", "released_by", "released_at", "published_at",
])
Document.objects.create(
batch=coa.batch,
doc_type=Document.DocType.COA,
file_url=coa.file_url,
file_name=coa.file_name,
visibility=Document.Visibility.BUYER,
uploaded_by=released_by,
)
for buyer in _buyer_users():
Notification.objects.create(
user=buyer,
type="coa_published",
title=f"Certificate of Analysis published: {coa.batch.batch_reference}",
message=f"A Certificate of Analysis is now available for batch {coa.batch.batch_reference}.",
)
return coa
def _buyer_users():
from apps.users.models import User
return User.objects.filter(role=User.Roles.BUYER, is_active=True)
# ---------------------------------------------------------------------------
# EUDR evidence workflow
# ---------------------------------------------------------------------------
def ensure_due_diligence_case(batch):
from apps.compliance.models import DueDiligenceCase
case, _ = DueDiligenceCase.objects.get_or_create(batch=batch)
return case
def _lineage(batch):
return list(batch.intake_lineage.select_related("harvest_intake", "harvest_intake__origin_zone", "harvest_intake__collector_group").all())
def run_completeness_check(case):
"""Evaluate evidence completeness for a due diligence case.
Outputs a JSON results map and the resulting case status. The system never
labels a case 'EUDR compliant' - it only reports evidence state.
"""
from apps.compliance.models import DueDiligenceCase
batch = case.batch
links = _lineage(batch)
results = {}
if links:
zone_statuses = []
for link in links:
intake = link.harvest_intake
zone = intake.origin_zone
if zone is None:
zone_statuses.append("no_zone")
elif zone.verification_status == "verified":
zone_statuses.append("verified")
elif zone.verification_status == "rejected":
zone_statuses.append("rejected")
else:
zone_statuses.append("unverified")
results["geolocation"] = {
"pass": all(s == "verified" for s in zone_statuses),
"detail": "All intakes trace to a verified origin zone" if all(s == "verified" for s in zone_statuses)
else f"Zone states: {', '.join(zone_statuses)}",
}
else:
results["geolocation"] = {"pass": False, "detail": "No intake lineage recorded for batch"}
results["lineage"] = {
"pass": len(links) > 0,
"detail": f"{len(links)} harvest intake(s) linked to batch" if links else "Batch has no intake lineage",
}
complete_intakes = all(
link.harvest_intake.intake_date and link.harvest_intake.weight_kg is not None
for link in links
) if links else False
results["intake_data"] = {
"pass": complete_intakes,
"detail": "Intake date and weight present" if complete_intakes else "Intake date/weight missing",
}
required_types = {"geolocation", "harvest_document", "supplier_identity"}
evidence_types = {e.evidence_type for e in case.evidence.all()}
evidence_pass = required_types.issubset(evidence_types)
results["evidence"] = {
"pass": evidence_pass,
"detail": "Required evidence present" if evidence_pass
else f"Missing evidence types: {', '.join(sorted(required_types - evidence_types))}",
}
risk_records = case.risk_assessments.count()
results["risk_assessment"] = {
"pass": risk_records > 0,
"detail": f"{risk_records} risk assessment(s) recorded" if risk_records else "No risk assessment recorded",
}
case.completeness_results = results
if case.status in (DueDiligenceCase.Status.APPROVED, DueDiligenceCase.Status.REJECTED):
case.save(update_fields=["completeness_results", "updated_at"])
return case
all_pass = all(r["pass"] for r in results.values())
if not all_pass:
case.status = DueDiligenceCase.Status.INCOMPLETE
else:
if case.risk_level == DueDiligenceCase.RiskLevel.HIGH or case.risk_assessments.filter(risk_level="high").exists():
case.status = DueDiligenceCase.Status.MITIGATION_REQUIRED
else:
case.status = DueDiligenceCase.Status.COMPLETE_FOR_REVIEW
case.save(update_fields=["status", "completeness_results", "updated_at"])
return case
def evaluate_risk(case, user=None):
"""Rule-based risk assessment. Transparency over inference: each finding is
grounded in a concrete, reviewable rule. No AI-derived legal conclusions."""
from apps.compliance.models import RiskAssessment
findings = []
level = "standard"
links = _lineage(case.batch)
if not links:
level = "high"
findings.append("No intake lineage recorded - risk of untraceable origin")
else:
unverified = any(
l.harvest_intake.origin_zone is None or l.harvest_intake.origin_zone.verification_status != "verified"
for l in links
)
if unverified:
level = "high"
findings.append("One or more origin zones unverified or missing")
expired_or_missing = not case.evidence.exists()
if expired_or_missing:
findings.append("Required EUDR evidence absent")
# Regulatory context (transparent, not a legal conclusion).
findings.append("Shea butter is not listed in EUDR Annex I; this tracker provides due-diligence "
"evidence and buyer assurance, not an EU-mandated filing")
risk = RiskAssessment.objects.create(
case=case,
risk_level=level,
basis="; ".join(findings),
findings="; ".join(findings),
assessed_by=user,
assessed_at=timezone.now(),
)
case.risk_level = level
case.assessed_by = user
case.assessed_at = timezone.now()
if level == "high":
case.status = "mitigation_required"
case.save(update_fields=["risk_level", "assessed_by", "assessed_at", "status", "updated_at"])
return risk
def approve_case(case, user):
"""Named compliance approval of a complete case."""
if case.status not in ("complete_for_review", "mitigation_required", "risk_identified"):
raise ValueError("Case is not ready for approval")
open_mitigations = case.mitigation_actions.exclude(status="verified").exists()
if case.risk_level == "high" and open_mitigations:
raise ValueError("High-risk case has unverified mitigation actions")
case.status = "approved"
case.approved_by = user
case.approved_at = timezone.now()
case.save(update_fields=["status", "approved_by", "approved_at", "updated_at"])
return case
def reject_case(case, user, reason):
if not reason:
raise ValueError("Rejection reason is required")
case.status = "rejected"
case.approved_by = user
case.approved_at = timezone.now()
case.rejected_reason = reason
case.save(update_fields=["status", "approved_by", "approved_at", "rejected_reason", "updated_at"])
return case
def record_submission(case, user, dds_reference, submission_date):
from apps.compliance.models import DueDiligenceStatementReference
return DueDiligenceStatementReference.objects.create(
case=case,
dds_reference=dds_reference,
submission_date=submission_date,
submitted_by=user,
)
def evidence_package(case):
"""Assemble the exportable evidence package summary."""
batch = case.batch
intakes = [l.harvest_intake for l in _lineage(batch)]
return {
"batch_reference": batch.batch_reference,
"product": DEFAULT_PRODUCT,
"weight_kg": float(batch.weight_kg) if batch.weight_kg is not None else None,
"origin_zones": [
{"zone_code": i.origin_zone.zone_code, "country": i.origin_zone.country,
"verification_status": i.origin_zone.verification_status}
for i in intakes if i.origin_zone
],
"collector_groups": [
{"name": i.collector_group.name, "contact_reference": i.collector_group.contact_reference}
for i in intakes if i.collector_group
],
"intake_count": len(intakes),
"risk_level": case.risk_level,
"status": case.status,
"approved_by": case.approved_by.full_name if case.approved_by else None,
"approved_at": case.approved_at.isoformat() if case.approved_at else None,
"dds_references": [
{"dds_reference": r.dds_reference, "submission_date": r.submission_date.isoformat() if r.submission_date else None}
for r in case.statement_references.all()
],
}

305
apps/compliance/tests.py Normal file
View file

@ -0,0 +1,305 @@
import os
import tempfile
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from apps.batches.models import ProcessingBatch
from apps.capa.models import CAPARecord
from apps.documents.models import CertificateOfAnalysis, Document
from apps.notifications.models import Notification
from apps.qc.models import QCResult
from .models import (
CollectorGroup, DueDiligenceCase, Facility, HarvestIntake, IntakeBatchLineage,
OriginZone, ProductSpecification, SpecParameter,
)
from . import services
User = get_user_model()
def make_user(email, role):
# Mirrors the Keycloak OIDC backend, which creates all users as is_staff=True.
return User.objects.create_user(
email=email, password="pass1234", role=role,
full_name=email.split("@")[0], is_staff=True,
)
def make_batch(user):
return ProcessingBatch.objects.create(
batch_reference=f"IXG-BATCH-TEST-{ProcessingBatch.objects.count() + 1}",
grade="A",
weight_kg=Decimal("1000.00"),
origin_facility="ASI - Katsina State, Nigeria",
created_by=user,
)
class SpecEvaluationTests(TestCase):
def setUp(self):
self.spec = ProductSpecification.objects.create(
product="Unrefined Shea Butter", version="9.9", status="approved", is_current=True
)
SpecParameter.objects.create(spec=self.spec, parameter="ffa", unit="%", max_value=Decimal("3.00"), sort_order=1)
SpecParameter.objects.create(spec=self.spec, parameter="moisture", unit="%", max_value=Decimal("0.10"), sort_order=2)
SpecParameter.objects.create(spec=self.spec, parameter="peroxide", unit="meq O2/kg", max_value=Decimal("10.00"), sort_order=3)
SpecParameter.objects.create(spec=self.spec, parameter="visual", unit="grade", allowed_values="A,B,C", sort_order=4)
def test_pass_within_spec(self):
values = {"ffa": "2.0", "moisture": "0.05", "peroxide": "5.0", "visual_grade": "A"}
overall_pass, result_status, _, version = services.evaluate_qc_data(values, spec=self.spec)
self.assertTrue(overall_pass)
self.assertEqual(result_status, QCResult.ResultStatus.WITHIN_SPEC)
self.assertEqual(version, "9.9")
def test_fail_outside_spec(self):
values = {"ffa": "5.0", "moisture": "0.05", "peroxide": "5.0", "visual_grade": "A"}
overall_pass, result_status, _, _ = services.evaluate_qc_data(values, spec=self.spec)
self.assertFalse(overall_pass)
self.assertEqual(result_status, QCResult.ResultStatus.OUTSIDE_SPEC)
def test_fail_visual_grade(self):
values = {"ffa": "2.0", "moisture": "0.05", "peroxide": "5.0", "visual_grade": "Fail"}
overall_pass, result_status, _, _ = services.evaluate_qc_data(values, spec=self.spec)
self.assertFalse(overall_pass)
def test_fallback_legacy_limits_when_no_spec(self):
values = {"ffa": "2.0", "moisture": "0.05", "peroxide": "5.0", "visual_grade": "A"}
overall_pass, result_status, _, version = services.evaluate_qc_data(values, spec=None)
self.assertTrue(overall_pass)
self.assertEqual(version, "")
class QCWorkflowTests(TestCase):
def setUp(self):
self.ops_a = make_user("ops.a@ixg.local", "ops")
self.ops_b = make_user("ops.b@ixg.local", "ops")
self.buyer = make_user("buyer@ixg.local", "buyer")
self.batch = make_batch(self.ops_a)
self.client = APIClient()
def create_qc(self, values, user=None):
self.client.force_authenticate(user=user or self.ops_a)
payload = {
"batch": str(self.batch.id),
"ffa_percentage": values[0],
"moisture_percentage": values[1],
"peroxide_value": values[2],
"visual_grade": values[3],
}
return self.client.post("/api/qc-results/", payload, format="json")
def test_qc_fail_creates_capa_and_quarantine(self):
resp = self.create_qc(["9.0", "0.9", "20", "Fail"])
self.assertEqual(resp.status_code, 201)
qc = QCResult.objects.get(batch=self.batch)
self.assertFalse(qc.overall_pass)
self.assertTrue(qc.is_quarantine)
self.assertEqual(qc.entered_by, self.ops_a)
self.assertEqual(qc.result_status, QCResult.ResultStatus.OUTSIDE_SPEC)
self.batch.refresh_from_db()
self.assertEqual(self.batch.status, ProcessingBatch.Status.QC_FAIL)
self.assertTrue(CAPARecord.objects.filter(batch=self.batch).exists())
def test_qc_pass_sets_batch_qc_pass(self):
resp = self.create_qc(["2.0", "0.05", "5.0", "A"])
self.assertEqual(resp.status_code, 201)
qc = QCResult.objects.get(batch=self.batch)
self.assertTrue(qc.overall_pass)
self.batch.refresh_from_db()
self.assertEqual(self.batch.status, ProcessingBatch.Status.QC_PASS)
def test_verify_four_eyes_blocks_submitter(self):
qc = QCResult.objects.create(
batch=self.batch, ffa_percentage=Decimal("2.0"), moisture_percentage=Decimal("0.05"),
peroxide_value=Decimal("5.0"), visual_grade="A", overall_pass=True, entered_by=self.ops_a,
)
self.client.force_authenticate(user=self.ops_a)
resp = self.client.post(f"/api/qc-results/{qc.id}/verify/", {"approved": "true"}, format="json")
self.assertEqual(resp.status_code, 400)
qc.refresh_from_db()
self.assertEqual(qc.verification_status, QCResult.VerificationStatus.DRAFT)
def test_verify_approval_generates_coa(self):
qc = QCResult.objects.create(
batch=self.batch, ffa_percentage=Decimal("2.0"), moisture_percentage=Decimal("0.05"),
peroxide_value=Decimal("5.0"), visual_grade="A", overall_pass=True, entered_by=self.ops_a,
spec_version="1.0",
)
self.client.force_authenticate(user=self.ops_b)
resp = self.client.post(f"/api/qc-results/{qc.id}/verify/", {"approved": "true"}, format="json")
self.assertEqual(resp.status_code, 200)
qc.refresh_from_db()
self.assertEqual(qc.verification_status, QCResult.VerificationStatus.APPROVED)
self.assertEqual(qc.verified_by, self.ops_b)
coa = CertificateOfAnalysis.objects.get(qc_result=qc)
self.assertEqual(coa.status, CertificateOfAnalysis.Status.PENDING_APPROVAL)
self.assertTrue(coa.sha256_hash)
self.assertTrue(os.path.exists(coa.file_url.replace("/media/", "media/")))
@override_settings(MEDIA_ROOT=tempfile.mkdtemp(prefix="ixg_test_media_"))
def test_publish_coa_creates_buyer_document_and_notification(self):
qc = QCResult.objects.create(
batch=self.batch, ffa_percentage=Decimal("2.0"), moisture_percentage=Decimal("0.05"),
peroxide_value=Decimal("5.0"), visual_grade="A", overall_pass=True, entered_by=self.ops_a,
spec_version="1.0",
)
coa = services.create_coa_for_qc(qc, approved_by=self.ops_b)
services.publish_coa(coa, released_by=self.ops_b)
coa.refresh_from_db()
self.assertEqual(coa.status, CertificateOfAnalysis.Status.PUBLISHED)
buyer_doc = Document.objects.filter(batch=self.batch, doc_type="coa", visibility="buyer")
self.assertEqual(buyer_doc.count(), 1)
self.assertEqual(buyer_doc.first().file_url, coa.file_url)
self.assertTrue(Notification.objects.filter(user=self.buyer, type="coa_published").exists())
def test_buyer_verification_forbidden(self):
qc = QCResult.objects.create(
batch=self.batch, ffa_percentage=Decimal("2.0"), moisture_percentage=Decimal("0.05"),
peroxide_value=Decimal("5.0"), visual_grade="A", overall_pass=True, entered_by=self.ops_a,
)
self.client.force_authenticate(user=self.buyer)
resp = self.client.post(f"/api/qc-results/{qc.id}/verify/", {"approved": "true"}, format="json")
self.assertEqual(resp.status_code, 403)
class EUDRWorkflowTests(TestCase):
def setUp(self):
self.ops = make_user("ops.eudr@ixg.local", "ops")
self.buyer = make_user("buyer.eudr@ixg.local", "buyer")
self.batch = make_batch(self.ops)
def test_completeness_missing_lineage_blocks(self):
case = DueDiligenceCase.objects.create(batch=self.batch)
services.run_completeness_check(case)
case.refresh_from_db()
self.assertEqual(case.status, DueDiligenceCase.Status.INCOMPLETE)
self.assertFalse(case.completeness_results["geolocation"]["pass"])
self.assertFalse(case.completeness_results["lineage"]["pass"])
def test_completeness_unverified_zone_blocks(self):
facility = Facility.objects.create(name="ASI Katsina", country="Nigeria", region="Katsina")
zone = OriginZone.objects.create(
facility=facility, zone_code="KAT-01", country="Nigeria", region="Katsina",
verification_status=OriginZone.VerificationStatus.UNVERIFIED,
)
group = CollectorGroup.objects.create(name="Group A", origin_zone=zone)
intake = HarvestIntake.objects.create(
intake_reference="INT-001", collector_group=group, origin_zone=zone,
intake_date="2026-01-10", weight_kg=Decimal("500.00"),
)
IntakeBatchLineage.objects.create(batch=self.batch, harvest_intake=intake, weight_allocated_kg=Decimal("500.00"))
case = services.ensure_due_diligence_case(self.batch)
services.run_completeness_check(case)
case.refresh_from_db()
self.assertEqual(case.status, DueDiligenceCase.Status.INCOMPLETE)
self.assertFalse(case.completeness_results["geolocation"]["pass"])
def test_verified_zone_with_lineage_passes_geolocation(self):
facility = Facility.objects.create(name="ASI Katsina", country="Nigeria", region="Katsina")
zone = OriginZone.objects.create(
facility=facility, zone_code="KAT-02", country="Nigeria", region="Katsina",
verification_status=OriginZone.VerificationStatus.VERIFIED,
)
group = CollectorGroup.objects.create(name="Group B", origin_zone=zone)
intake = HarvestIntake.objects.create(
intake_reference="INT-002", collector_group=group, origin_zone=zone,
intake_date="2026-01-10", weight_kg=Decimal("500.00"),
)
IntakeBatchLineage.objects.create(batch=self.batch, harvest_intake=intake, weight_allocated_kg=Decimal("500.00"))
case = services.ensure_due_diligence_case(self.batch)
services.run_completeness_check(case)
case.refresh_from_db()
self.assertTrue(case.completeness_results["geolocation"]["pass"])
self.assertTrue(case.completeness_results["lineage"]["pass"])
def test_risk_high_without_lineage(self):
case = DueDiligenceCase.objects.create(batch=self.batch)
risk = services.evaluate_risk(case, user=self.ops)
case.refresh_from_db()
self.assertEqual(risk.risk_level, "high")
self.assertEqual(case.risk_level, DueDiligenceCase.RiskLevel.HIGH)
self.assertEqual(case.status, DueDiligenceCase.Status.MITIGATION_REQUIRED)
def test_approve_case_requires_complete_state(self):
case = DueDiligenceCase.objects.create(batch=self.batch)
with self.assertRaises(ValueError):
services.approve_case(case, self.ops)
def test_record_submission(self):
case = DueDiligenceCase.objects.create(batch=self.batch)
ref = services.record_submission(case, self.ops, "DD-2026-0001", "2026-08-05")
self.assertEqual(ref.dds_reference, "DD-2026-0001")
self.assertEqual(case.statement_references.count(), 1)
class BuyerIsolationTests(TestCase):
def setUp(self):
self.buyer = make_user("iso.buyer@ixg.local", "buyer")
self.ops = make_user("iso.ops@ixg.local", "ops")
self.client = APIClient()
def test_buyer_cannot_access_compliance_api(self):
self.client.force_authenticate(user=self.buyer)
resp = self.client.get("/api/product-specs/")
self.assertEqual(resp.status_code, 403)
resp = self.client.get("/api/eudr-cases/")
self.assertEqual(resp.status_code, 403)
def test_ops_can_access_compliance_api(self):
self.client.force_authenticate(user=self.ops)
resp = self.client.get("/api/product-specs/")
self.assertEqual(resp.status_code, 200)
resp = self.client.get("/api/eudr-cases/")
self.assertEqual(resp.status_code, 200)
def test_buyer_forbidden_from_ops(self):
self.client.force_login(self.buyer)
resp = self.client.get("/ops/")
self.assertEqual(resp.status_code, 403)
class PageRenderTests(TestCase):
def setUp(self):
self.ops = make_user("page.ops@ixg.local", "ops")
self.buyer = make_user("page.buyer@ixg.local", "buyer")
self.batch = make_batch(self.ops)
self.qc = QCResult.objects.create(
batch=self.batch, ffa_percentage=Decimal("2.0"), moisture_percentage=Decimal("0.05"),
peroxide_value=Decimal("5.0"), visual_grade="A", overall_pass=True,
entered_by=self.ops, spec_version="1.0",
)
self.coa = services.create_coa_for_qc(self.qc, approved_by=self.ops)
self.case = DueDiligenceCase.objects.create(batch=self.batch)
def test_ops_pages_render(self):
self.client.force_login(self.ops)
for url in [
"/ops/qc/verify/",
f"/ops/qc/verify/{self.qc.id}/",
"/ops/coa/",
f"/ops/coa/{self.coa.id}/",
"/ops/eudr/",
f"/ops/eudr/{self.case.id}/",
]:
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200, msg=f"GET {url}")
def test_buyer_compliance_page_renders(self):
self.client.force_login(self.buyer)
resp = self.client.get("/portal/compliance/")
self.assertEqual(resp.status_code, 200)
def test_ops_post_publish_coa(self):
self.client.force_login(self.ops)
resp = self.client.post(f"/ops/coa/{self.coa.id}/", {"action": "approve"})
self.assertEqual(resp.status_code, 302)
self.coa.refresh_from_db()
self.assertEqual(self.coa.status, CertificateOfAnalysis.Status.PUBLISHED)

20
apps/compliance/urls.py Normal file
View file

@ -0,0 +1,20 @@
from django.urls import path, include
from rest_framework.routers import DefaultRouter
from . import views
router = DefaultRouter()
router.register("product-specs", views.ProductSpecificationViewSet)
router.register("facilities", views.FacilityViewSet)
router.register("origin-zones", views.OriginZoneViewSet)
router.register("collector-groups", views.CollectorGroupViewSet)
router.register("harvest-intakes", views.HarvestIntakeViewSet)
router.register("intake-batch-lineage", views.IntakeBatchLineageViewSet)
router.register("eudr-cases", views.DueDiligenceCaseViewSet)
router.register("eudr-evidence", views.EUDREvidenceViewSet)
router.register("risk-assessments", views.RiskAssessmentViewSet)
router.register("mitigation-actions", views.MitigationActionViewSet)
router.register("dds-references", views.DueDiligenceStatementReferenceViewSet)
urlpatterns = [
path("", include(router.urls)),
]

170
apps/compliance/views.py Normal file
View file

@ -0,0 +1,170 @@
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import (
ProductSpecification, Facility, OriginZone, CollectorGroup, HarvestIntake,
IntakeBatchLineage, DueDiligenceCase, EUDREvidence, RiskAssessment,
MitigationAction, DueDiligenceStatementReference,
)
from .serializers import (
ProductSpecificationSerializer, FacilitySerializer, OriginZoneSerializer,
CollectorGroupSerializer, HarvestIntakeSerializer, IntakeBatchLineageSerializer,
DueDiligenceCaseSerializer, EUDREvidenceSerializer, RiskAssessmentSerializer,
MitigationActionSerializer, DueDiligenceStatementReferenceSerializer,
)
from .permissions import IsOpsOrAdmin, IsAdmin
from . import services
class ProductSpecificationViewSet(viewsets.ModelViewSet):
queryset = ProductSpecification.objects.prefetch_related("parameters").all()
serializer_class = ProductSpecificationSerializer
permission_classes = [IsOpsOrAdmin]
def get_permissions(self):
if self.action in ("create", "update", "partial_update", "destroy"):
return [permission() for permission in [IsAdmin]]
return [permission() for permission in [IsOpsOrAdmin]]
class FacilityViewSet(viewsets.ModelViewSet):
queryset = Facility.objects.all()
serializer_class = FacilitySerializer
permission_classes = [IsOpsOrAdmin]
class OriginZoneViewSet(viewsets.ModelViewSet):
queryset = OriginZone.objects.select_related("facility", "verified_by").all()
serializer_class = OriginZoneSerializer
permission_classes = [IsOpsOrAdmin]
@action(detail=True, methods=["post"])
def verify(self, request, pk=None):
zone = self.get_object()
outcome = request.data.get("verified", "true")
verified = str(outcome).lower() in ("true", "1", "yes")
zone.verification_status = "verified" if verified else "rejected"
zone.verified_by = request.user
from django.utils import timezone
zone.verified_at = timezone.now()
zone.save()
return Response(OriginZoneSerializer(zone).data)
class CollectorGroupViewSet(viewsets.ModelViewSet):
queryset = CollectorGroup.objects.all()
serializer_class = CollectorGroupSerializer
permission_classes = [IsOpsOrAdmin]
class HarvestIntakeViewSet(viewsets.ModelViewSet):
queryset = HarvestIntake.objects.select_related("origin_zone", "collector_group").all()
serializer_class = HarvestIntakeSerializer
permission_classes = [IsOpsOrAdmin]
class IntakeBatchLineageViewSet(viewsets.ModelViewSet):
queryset = IntakeBatchLineage.objects.select_related("batch", "harvest_intake").all()
serializer_class = IntakeBatchLineageSerializer
permission_classes = [IsOpsOrAdmin]
def perform_create(self, serializer):
lineage = serializer.save()
services.ensure_due_diligence_case(lineage.batch)
class EUDREvidenceViewSet(viewsets.ModelViewSet):
queryset = EUDREvidence.objects.select_related("case", "verified_by").all()
serializer_class = EUDREvidenceSerializer
permission_classes = [IsOpsOrAdmin]
def perform_create(self, serializer):
evidence = serializer.save()
case = evidence.case
services.run_completeness_check(case)
class RiskAssessmentViewSet(viewsets.ModelViewSet):
queryset = RiskAssessment.objects.select_related("case", "assessed_by").all()
serializer_class = RiskAssessmentSerializer
permission_classes = [IsOpsOrAdmin]
class MitigationActionViewSet(viewsets.ModelViewSet):
queryset = MitigationAction.objects.select_related("case").all()
serializer_class = MitigationActionSerializer
permission_classes = [IsOpsOrAdmin]
@action(detail=True, methods=["post"])
def complete(self, request, pk=None):
mitigation = self.get_object()
mitigation.status = MitigationAction.Status.VERIFIED
from django.utils import timezone
mitigation.completed_at = timezone.now()
mitigation.save()
services.run_completeness_check(mitigation.case)
return Response(MitigationActionSerializer(mitigation).data)
class DueDiligenceStatementReferenceViewSet(viewsets.ModelViewSet):
queryset = DueDiligenceStatementReference.objects.select_related("case", "submitted_by").all()
serializer_class = DueDiligenceStatementReferenceSerializer
permission_classes = [IsOpsOrAdmin]
class DueDiligenceCaseViewSet(viewsets.ModelViewSet):
queryset = DueDiligenceCase.objects.select_related(
"batch", "approved_by", "assessed_by"
).prefetch_related("evidence", "risk_assessments", "mitigation_actions", "statement_references").order_by("-updated_at").all()
serializer_class = DueDiligenceCaseSerializer
permission_classes = [IsOpsOrAdmin]
def perform_create(self, serializer):
serializer.save()
@action(detail=True, methods=["post"])
def check(self, request, pk=None):
case = self.get_object()
services.run_completeness_check(case)
return Response(DueDiligenceCaseSerializer(case).data)
@action(detail=True, methods=["post"])
def assess_risk(self, request, pk=None):
case = self.get_object()
risk = services.evaluate_risk(case, user=request.user)
return Response({"risk_assessment_id": str(risk.id), **RiskAssessmentSerializer(risk).data})
@action(detail=True, methods=["post"])
def approve(self, request, pk=None):
case = self.get_object()
try:
services.approve_case(case, request.user)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(DueDiligenceCaseSerializer(case).data)
@action(detail=True, methods=["post"])
def reject(self, request, pk=None):
case = self.get_object()
reason = request.data.get("reason", "")
try:
services.reject_case(case, request.user, reason)
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response(DueDiligenceCaseSerializer(case).data)
@action(detail=True, methods=["post"])
def record_submission(self, request, pk=None):
case = self.get_object()
dds_reference = request.data.get("dds_reference", "")
submission_date = request.data.get("submission_date")
if not dds_reference:
return Response({"detail": "dds_reference is required"}, status=status.HTTP_400_BAD_REQUEST)
ref = services.record_submission(case, request.user, dds_reference, submission_date)
return Response(DueDiligenceStatementReferenceSerializer(ref).data)
@action(detail=True, methods=["get"])
def evidence_package(self, request, pk=None):
case = self.get_object()
return Response(services.evidence_package(case))

View file

@ -0,0 +1,87 @@
# Generated by Django 5.2.12 on 2026-08-05 13:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('documents', '0001_initial'),
('qc', '0002_alter_qcresult_options_qcresult_approved_at_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='certificateofanalysis',
name='published_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='certificateofanalysis',
name='qc_result',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='certificates', to='qc.qcresult'),
),
migrations.AddField(
model_name='certificateofanalysis',
name='quality_approved_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='certificateofanalysis',
name='quality_approved_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='coas_quality_approved', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='certificateofanalysis',
name='released_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='certificateofanalysis',
name='released_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='coas_released', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='certificateofanalysis',
name='sha256_hash',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='certificateofanalysis',
name='source_document',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='source_for_coa', to='documents.document'),
),
migrations.AddField(
model_name='certificateofanalysis',
name='spec_version',
field=models.CharField(blank=True, max_length=20),
),
migrations.AddField(
model_name='certificateofanalysis',
name='status',
field=models.CharField(choices=[('pending_approval', 'Pending Approval'), ('published', 'Published'), ('superseded', 'Superseded')], default='pending_approval', max_length=20),
),
migrations.AddField(
model_name='certificateofanalysis',
name='superseded_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='certificateofanalysis',
name='superseded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='supersedes', to='documents.certificateofanalysis'),
),
migrations.AddField(
model_name='certificateofanalysis',
name='version',
field=models.IntegerField(default=1),
),
migrations.AlterField(
model_name='document',
name='doc_type',
field=models.CharField(choices=[('coa', 'Certificate of Analysis'), ('lab_report', 'Laboratory Report'), ('regulatory', 'Regulatory'), ('invoice', 'Invoice'), ('packing_list', 'Packing List'), ('bl', 'Bill of Lading'), ('insurance', 'Insurance'), ('other', 'Other')], max_length=20),
),
]

View file

@ -4,8 +4,16 @@ from django.db import models
class CertificateOfAnalysis(models.Model):
class Status(models.TextChoices):
PENDING_APPROVAL = "pending_approval", "Pending Approval"
PUBLISHED = "published", "Published"
SUPERSEDED = "superseded", "Superseded"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
batch = models.ForeignKey("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="certificates_of_analysis")
qc_result = models.ForeignKey(
"qc.QCResult", on_delete=models.SET_NULL, null=True, blank=True, related_name="certificates"
)
file_url = models.TextField()
file_name = models.CharField(max_length=255)
issuer = models.CharField(max_length=255, blank=True)
@ -14,6 +22,27 @@ class CertificateOfAnalysis(models.Model):
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True)
uploaded_at = models.DateTimeField(auto_now_add=True)
status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING_APPROVAL)
version = models.IntegerField(default=1)
sha256_hash = models.CharField(max_length=64, blank=True)
spec_version = models.CharField(max_length=20, blank=True)
source_document = models.ForeignKey(
"Document", on_delete=models.SET_NULL, null=True, blank=True, related_name="source_for_coa"
)
quality_approved_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="coas_quality_approved"
)
quality_approved_at = models.DateTimeField(null=True, blank=True)
released_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="coas_released"
)
released_at = models.DateTimeField(null=True, blank=True)
published_at = models.DateTimeField(null=True, blank=True)
superseded_at = models.DateTimeField(null=True, blank=True)
superseded_by = models.ForeignKey(
"self", on_delete=models.SET_NULL, null=True, blank=True, related_name="supersedes"
)
class Meta:
db_table = "certificates_of_analysis"
verbose_name_plural = "Certificates of Analysis"
@ -52,6 +81,7 @@ class RegulatoryCert(models.Model):
class Document(models.Model):
class DocType(models.TextChoices):
COA = "coa", "Certificate of Analysis"
LAB_REPORT = "lab_report", "Laboratory Report"
REGULATORY = "regulatory", "Regulatory"
INVOICE = "invoice", "Invoice"
PACKING_LIST = "packing_list", "Packing List"

View file

@ -51,7 +51,7 @@ class DocumentViewSet(viewsets.ModelViewSet):
doc = Document.objects.create(
batch_id=batch_id,
doc_type=doc_type,
file_url=f"/media/ixg-documents/{batch_id or uncategorized}/{filename}",
file_url=f"/media/ixg-documents/{batch_id or 'uncategorized'}/{filename}",
file_name=file.name,
file_size_bytes=file.size,
visibility=visibility,
@ -62,10 +62,51 @@ class DocumentViewSet(viewsets.ModelViewSet):
class CertificateOfAnalysisViewSet(viewsets.ModelViewSet):
queryset = CertificateOfAnalysis.objects.all()
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()

View file

@ -15,4 +15,10 @@ urlpatterns = [
path('capa/<uuid:pk>/', views.CAPADetailView.as_view(), name='capa_detail'),
path('documents/', views.DocumentListView.as_view(), name='document_list'),
path('esg/', views.ESGListView.as_view(), name='esg_list'),
path('qc/verify/', views.QCVerifyListView.as_view(), name='qc_verify_list'),
path('qc/verify/<uuid:pk>/', views.QCVerifyDetailView.as_view(), name='qc_verify_detail'),
path('coa/', views.COAReviewListView.as_view(), name='coa_review_list'),
path('coa/<uuid:pk>/', views.COAReviewDetailView.as_view(), name='coa_review_detail'),
path('eudr/', views.EUDRCaseListView.as_view(), name='eudr_list'),
path('eudr/<uuid:pk>/', views.EUDRCaseDetailView.as_view(), name='eudr_detail'),
]

View file

@ -1,5 +1,6 @@
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
from django.db.models import Count, Q
from django.shortcuts import redirect
from django.utils import timezone
from django.views.generic import TemplateView, ListView, DetailView
from django.shortcuts import get_object_or_404
@ -8,9 +9,10 @@ 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.models import Document
from apps.documents.models import Document, CertificateOfAnalysis
from apps.esg.models import ESGFieldReport
from apps.notifications.models import Notification
from apps.compliance.models import DueDiligenceCase
class OpsRequiredMixin(UserPassesTestMixin):
@ -195,3 +197,176 @@ class ESGListView(OpsRequiredMixin, ListView):
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 django.contrib import messages
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)

View file

@ -0,0 +1,75 @@
# Generated by Django 5.2.12 on 2026-08-05 13:36
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('qc', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterModelOptions(
name='qcresult',
options={'ordering': ['-tested_at']},
),
migrations.AddField(
model_name='qcresult',
name='approved_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='qcresult',
name='entered_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qc_entered', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='qcresult',
name='is_quarantine',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='qcresult',
name='method_ffa',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='qcresult',
name='method_moisture',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='qcresult',
name='method_peroxide',
field=models.CharField(blank=True, max_length=255),
),
migrations.AddField(
model_name='qcresult',
name='result_status',
field=models.CharField(choices=[('within_spec', 'Within Specification'), ('outside_spec', 'Outside Specification'), ('missing', 'Missing'), ('unit_mismatch', 'Unit Mismatch')], default='within_spec', max_length=20),
),
migrations.AddField(
model_name='qcresult',
name='spec_version',
field=models.CharField(blank=True, max_length=20),
),
migrations.AddField(
model_name='qcresult',
name='superseded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='supersedes', to='qc.qcresult'),
),
migrations.AddField(
model_name='qcresult',
name='verification_status',
field=models.CharField(choices=[('draft', 'Draft'), ('validated', 'Validated'), ('approved', 'Approved'), ('rejected', 'Rejected')], default='draft', max_length=20),
),
migrations.AddField(
model_name='qcresult',
name='verified_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='qc_verified', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -1,4 +1,5 @@
import uuid
from django.conf import settings
from django.db import models
@ -9,6 +10,18 @@ class QCResult(models.Model):
C = "C", "C"
FAIL = "Fail", "Fail"
class VerificationStatus(models.TextChoices):
DRAFT = "draft", "Draft"
VALIDATED = "validated", "Validated"
APPROVED = "approved", "Approved"
REJECTED = "rejected", "Rejected"
class ResultStatus(models.TextChoices):
WITHIN_SPEC = "within_spec", "Within Specification"
OUTSIDE_SPEC = "outside_spec", "Outside Specification"
MISSING = "missing", "Missing"
UNIT_MISMATCH = "unit_mismatch", "Unit Mismatch"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
batch = models.ForeignKey("batches.ProcessingBatch", on_delete=models.CASCADE, related_name="qc_results")
ffa_percentage = models.DecimalField(max_digits=5, decimal_places=2)
@ -20,8 +33,32 @@ class QCResult(models.Model):
tested_at = models.DateTimeField(auto_now_add=True)
lab_reference = models.CharField(max_length=255, blank=True)
spec_version = models.CharField(max_length=20, blank=True)
entered_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="qc_entered"
)
verified_by = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="qc_verified"
)
verification_status = models.CharField(
max_length=20, choices=VerificationStatus.choices, default=VerificationStatus.DRAFT
)
approved_at = models.DateTimeField(null=True, blank=True)
method_ffa = models.CharField(max_length=255, blank=True)
method_moisture = models.CharField(max_length=255, blank=True)
method_peroxide = models.CharField(max_length=255, blank=True)
result_status = models.CharField(
max_length=20, choices=ResultStatus.choices, default=ResultStatus.WITHIN_SPEC
)
superseded_by = models.ForeignKey(
"self", on_delete=models.SET_NULL, null=True, blank=True, related_name="supersedes"
)
is_quarantine = models.BooleanField(default=False)
class Meta:
db_table = "qc_results"
ordering = ["-tested_at"]
def __str__(self):
return f"QC for {self.batch.batch_reference}: {PASS if self.overall_pass else FAIL}"
status = "PASS" if self.overall_pass else "FAIL"
return f"QC for {self.batch.batch_reference}: {status}"

View file

@ -2,33 +2,43 @@ from rest_framework import serializers
from .models import QCResult
from apps.batches.models import ProcessingBatch
from apps.capa.models import CAPARecord
from apps.compliance.services import evaluate_qc_data
class QCResultSerializer(serializers.ModelSerializer):
entered_by_name = serializers.CharField(source="entered_by.full_name", read_only=True)
verified_by_name = serializers.CharField(source="verified_by.full_name", read_only=True)
class Meta:
model = QCResult
fields = "__all__"
read_only_fields = ["id", "overall_pass", "tested_at"]
read_only_fields = ["id", "overall_pass", "tested_at", "entered_by", "verified_by"]
class QCResultCreateSerializer(serializers.ModelSerializer):
class Meta:
model = QCResult
fields = ["batch", "ffa_percentage", "moisture_percentage", "peroxide_value", "visual_grade", "tested_by", "lab_reference"]
fields = [
"batch", "ffa_percentage", "moisture_percentage", "peroxide_value", "visual_grade",
"tested_by", "lab_reference",
"method_ffa", "method_moisture", "method_peroxide",
]
def create(self, validated_data):
ffa = validated_data["ffa_percentage"]
moisture = validated_data["moisture_percentage"]
peroxide = validated_data["peroxide_value"]
visual = validated_data["visual_grade"]
overall_pass = all([
ffa <= 3,
moisture <= 0.1,
peroxide <= 10,
visual != "Fail",
])
values = {
"ffa": validated_data["ffa_percentage"],
"moisture": validated_data["moisture_percentage"],
"peroxide": validated_data["peroxide_value"],
"visual_grade": validated_data["visual_grade"],
}
overall_pass, result_status, _details, spec_version = evaluate_qc_data(values)
validated_data["overall_pass"] = overall_pass
validated_data["result_status"] = result_status
validated_data["spec_version"] = spec_version
user = self.context.get("request").user if self.context.get("request") else None
validated_data["entered_by"] = user
validated_data["is_quarantine"] = not overall_pass
qc_result = super().create(validated_data)
@ -37,7 +47,7 @@ class QCResultCreateSerializer(serializers.ModelSerializer):
batch.status = ProcessingBatch.Status.QC_PASS
else:
batch.status = ProcessingBatch.Status.QC_FAIL
batch.save()
batch.save(update_fields=["status", "updated_at"])
if not overall_pass:
CAPARecord.objects.create(

View file

@ -1,6 +1,17 @@
from rest_framework import viewsets, permissions
from django.utils import timezone
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import QCResult
from .serializers import QCResultSerializer, QCResultCreateSerializer
from apps.compliance.services import create_coa_for_qc
class IsOpsOrAdmin(permissions.BasePermission):
def has_permission(self, request, view):
user = request.user
return bool(user and user.is_authenticated and (user.role in ("ops", "admin") or user.is_superuser))
class QCResultViewSet(viewsets.ModelViewSet):
@ -11,3 +22,51 @@ class QCResultViewSet(viewsets.ModelViewSet):
if self.action == "create":
return QCResultCreateSerializer
return QCResultSerializer
def perform_create(self, serializer):
serializer.save()
@action(detail=True, methods=["post"], permission_classes=[IsOpsOrAdmin])
def verify(self, request, pk=None):
"""Four-eyes quality approval. A separate authorised user validates the
entered result. The submitter cannot approve their own result."""
qc = self.get_object()
user = request.user
if qc.verification_status == QCResult.VerificationStatus.APPROVED:
return Response({"detail": "QC result already approved"}, status=status.HTTP_400_BAD_REQUEST)
if qc.entered_by is not None and qc.entered_by_id == user.id:
return Response(
{"detail": "The submitter cannot approve their own QC result (four-eyes control)"},
status=status.HTTP_400_BAD_REQUEST,
)
approved = request.data.get("approved", "true")
approved = str(approved).lower() in ("true", "1", "yes")
if approved:
if not qc.overall_pass:
return Response(
{"detail": "A failed QC result cannot be approved; it remains quarantined with an open CAPA"},
status=status.HTTP_400_BAD_REQUEST,
)
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)
return Response(
{
"detail": "QC result approved",
"coa_id": str(coa.id),
"coa_status": coa.status,
"coa_url": coa.file_url,
}
)
qc.verification_status = QCResult.VerificationStatus.REJECTED
qc.verified_by = user
qc.approved_at = timezone.now()
qc.save()
return Response({"detail": "QC result rejected"})

View file

@ -41,6 +41,7 @@ INSTALLED_APPS = [
"apps.documents",
"apps.capa",
"apps.esg",
"apps.compliance",
"apps.ai",
"apps.notifications",
"apps.ops",

View file

@ -19,6 +19,7 @@ urlpatterns = [
path("api/", include("apps.documents.urls")),
path("api/", include("apps.capa.urls")),
path("api/", include("apps.esg.urls")),
path("api/", include("apps.compliance.urls")),
path("api/", include("apps.ai.urls")),
path("api/", include("apps.notifications.urls")),
path("ops/", include("apps.ops.urls")),

View file

@ -18,6 +18,7 @@ stripe==8.8.0
python-dateutil==2.8.2
markdown==3.7
bleach==6.2.0
reportlab==4.2.2
django-encrypted-model-fields
openai

View file

@ -33,6 +33,9 @@ body { font-size: .875rem; background: #f5f6fa; }
<li class=nav-item><a class=nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %} href={% url 'ops:dashboard' %}><i class=bi bi-speedometer2></i> Dashboard</a></li>
<li class=nav-item><a class=nav-link {% if 'batch' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:batch_list' %}><i class=bi bi-box-seam></i> Batches</a></li>
<li class=nav-item><a class=nav-link {% if 'qc' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:qc_list' %}><i class=bi bi-clipboard-check></i> QC Results</a></li>
<li class=nav-item><a class=nav-link {% if 'verify' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:qc_verify_list' %}><i class=bi bi-person-check></i> QC Approvals</a></li>
<li class=nav-item><a class=nav-link {% if 'coa' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:coa_review_list' %}><i class=bi bi-file-earmark-medical></i> CoA Reviews</a></li>
<li class=nav-item><a class=nav-link {% if 'eudr' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:eudr_list' %}><i class=bi bi-shield-check></i> EUDR Compliance</a></li>
<li class=nav-item><a class=nav-link {% if 'shipment' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:shipment_list' %}><i class=bi bi-truck></i> Shipments</a></li>
<li class=nav-item><a class=nav-link {% if 'capa' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:capa_list' %}><i class=bi bi-exclamation-triangle></i> CAPA Records</a></li>
<li class=nav-item><a class=nav-link {% if 'document' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:document_list' %}><i class=bi bi-file-earmark-text></i> Documents</a></li>

View file

@ -0,0 +1,65 @@
{% extends 'ops/base_ops.html' %}
{% block title %}CoA Review - IXG Operations{% endblock %}
{% block content %}
<div class=page-header d-flex justify-content-between align-items-center>
<h2>CoA Review: {{ coa.batch.batch_reference }}</h2>
<a href={% url 'ops:coa_review_list' %} class=btn btn-sm btn-outline-secondary><i class=bi bi-arrow-left></i> Back</a>
</div>
{% if messages %}
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
{% endif %}
<div class=row g-3>
<div class=col-md-7>
<div class=stat-card>
<h6 class=mb-3>Certificate Details</h6>
<table class=table table-sm table-borderless mb-0>
<tr><td class=text-secondary>Batch</td><td><a href={% url 'ops:batch_detail' coa.batch.pk %}>{{ coa.batch.batch_reference }}</a></td></tr>
<tr><td class=text-secondary>Status</td><td><span class=badge {% if coa.status == 'published' %}bg-success{% elif coa.status == 'superseded' %}bg-secondary{% else %}bg-warning text-dark{% endif %}>{{ coa.get_status_display }}</span></td></tr>
<tr><td class=text-secondary>Version</td><td>v{{ coa.version }}</td></tr>
<tr><td class=text-secondary>Specification</td><td>{{ coa.spec_version|default:'-' }}</td></tr>
<tr><td class=text-secondary>Issuer</td><td>{{ coa.issuer|default:'-' }}</td></tr>
<tr><td class=text-secondary>Issue Date</td><td>{{ coa.issue_date|date:'d/m/Y'|default:'-' }}</td></tr>
<tr><td class=text-secondary>Quality Approved By</td><td>{{ coa.quality_approved_by.full_name|default:coa.quality_approved_by.email|default:'-' }}</td></tr>
<tr><td class=text-secondary>Quality Approved At</td><td>{{ coa.quality_approved_at|date:'d/m/Y H:i'|default:'-' }}</td></tr>
<tr><td class=text-secondary>Released At</td><td>{{ coa.released_at|date:'d/m/Y H:i'|default:'-' }}</td></tr>
<tr><td class=text-secondary>Published At</td><td>{{ coa.published_at|date:'d/m/Y H:i'|default:'-' }}</td></tr>
<tr><td class=text-secondary>SHA-256</td><td><code>{{ coa.sha256_hash|default:'-' }}</code></td></tr>
<tr><td class=text-secondary>Summary PDF</td><td><a href="{{ coa.file_url }}" target=_blank class=text-decoration-none><i class=bi bi-file-earmark-pdf></i> {{ coa.file_name }}</a></td></tr>
</table>
</div>
</div>
<div class=col-md-5>
<div class=stat-card mb-3>
<h6 class=mb-3>Source Documents</h6>
<p class=small text-muted>Independent laboratory report (authoritative):</p>
{% if coa.source_document %}
<a href="{{ coa.source_document.file_url }}" target=_blank class=text-decoration-none><i class=bi bi-file-earmark-pdf></i> {{ coa.source_document.file_name }}</a>
{% else %}
<p class=text-muted mb-0>No lab report linked. <a href={% url 'ops:document_list' %}>Upload</a> the original laboratory PDF for the batch.</p>
{% endif %}
{% if lab_documents %}
<hr>
<p class=small text-muted mb-1>All lab documents for batch:</p>
{% for d in lab_documents %}<div><a href="{{ d.file_url }}" target=_blank class=text-decoration-none><i class=bi bi-file-earmark-pdf></i> {{ d.file_name }}</a></div>{% endfor %}
{% endif %}
</div>
<div class=stat-card>
<h6 class=mb-3>Commercial Release</h6>
{% if coa.status == 'pending_approval' %}
<form method=post>
{% csrf_token %}
<input type=hidden name=action value=approve>
<p class=text-muted>Publishing exposes the summary PDF to authorised buyers and triggers buyer notification.</p>
<button type=submit class="btn btn-success w-100"><i class=bi bi-send></i> Approve &amp; Publish</button>
</form>
{% else %}
<p class=text-muted mb-0">Commercial release approved. Buyer document and notification created.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,58 @@
{% extends 'ops/base_ops.html' %}
{% block title %}CoA Reviews - IXG Operations{% endblock %}
{% block content %}
<div class=page-header d-flex justify-content-between align-items-center>
<h2>Certificate of Analysis Reviews</h2>
<span class=text-secondary>{{ page_obj.paginator.count }} total</span>
</div>
<div class=filter-bar>
<form method=get class=row g-2 align-items-end>
<div class=col-auto>
<select name=status class=form-select form-select-sm>
<option value=>Pending Approval</option>
{% for val, label in status_choices %}
<option value={{ val }} {% if current_status == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class=col-auto>
<button type=submit class=btn btn-sm btn-primary>Filter</button>
<a href={% url 'ops:coa_review_list' %} class=btn btn-sm btn-outline-secondary>Clear</a>
</div>
</form>
</div>
<div class=stat-card p-0>
<table class=table table-hover mb-0>
<thead>
<tr><th>Batch</th><th>Version</th><th>Status</th><th>Spec</th><th>Quality Approved</th><th>Issued</th><th></th></tr>
</thead>
<tbody>
{% for c in coas %}
<tr>
<td><strong>{{ c.batch.batch_reference }}</strong></td>
<td>v{{ c.version }}</td>
<td><span class=badge {% if c.status == 'published' %}bg-success{% elif c.status == 'superseded' %}bg-secondary{% else %}bg-warning text-dark{% endif %}>{{ c.get_status_display }}</span></td>
<td>{{ c.spec_version|default:'-' }}</td>
<td>{{ c.quality_approved_by.full_name|default:'-' }}</td>
<td>{{ c.issue_date|date:'d/m/Y'|default:'-' }}</td>
<td><a href={% url 'ops:coa_review_detail' c.pk %} class="btn btn-sm {% if c.status == 'pending_approval' %}btn-primary{% else %}btn-outline-secondary{% endif %}">Review</a></td>
</tr>
{% empty %}
<tr><td colspan=7 class=text-muted text-center py-3>No CoA records</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.paginator.num_pages > 1 %}
<nav class=mt-3><ul class=pagination pagination-sm justify-content-center>
{% if page_obj.has_previous %}<li class=page-item><a class=page-link href=?page=1>&laquo;</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
</ul></nav>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,141 @@
{% extends 'ops/base_ops.html' %}
{% block title %}EUDR Case - IXG Operations{% endblock %}
{% block content %}
<div class=page-header d-flex justify-content-between align-items-center>
<h2>EUDR Case: {{ case.batch.batch_reference }}</h2>
<a href={% url 'ops:eudr_list' %} class=btn btn-sm btn-outline-secondary><i class=bi bi-arrow-left></i> Back</a>
</div>
{% if messages %}
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
{% endif %}
<div class=row g-3>
<div class=col-md-8>
<div class=stat-card mb-3>
<h6 class=mb-3>Completeness Check</h6>
{% if case.completeness_results %}
<table class=table table-sm mb-0>
<thead><tr><th>Check</th><th>Status</th><th>Detail</th></tr></thead>
<tbody>
{% for key, item in case.completeness_results.items %}
<tr>
<td>{{ key }}</td>
<td>{% if item.pass %}<span class=badge bg-success>Pass</span>{% else %}<span class=badge bg-danger>Fail</span>{% endif %}</td>
<td>{{ item.detail }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class=text-muted mb-0>Not yet run.</p>
{% endif %}
</div>
<div class=stat-card mb-3>
<h6 class=mb-3>Intake Lineage</h6>
{% if lineage %}
<table class=table table-sm mb-0>
<thead><tr><th>Intake</th><th>Date</th><th>Weight (kg)</th><th>Zone</th><th>Zone Status</th><th>Collector</th></tr></thead>
<tbody>
{% for link in lineage %}
<tr>
<td>{{ link.harvest_intake.intake_reference }}</td>
<td>{{ link.harvest_intake.intake_date|date:'d/m/Y'|default:'-' }}</td>
<td>{{ link.weight_allocated_kg|default:link.harvest_intake.weight_kg }}</td>
<td>{{ link.harvest_intake.origin_zone.zone_code|default:'-' }}</td>
<td>{% if link.harvest_intake.origin_zone %}<span class=badge {% if link.harvest_intake.origin_zone.verification_status == 'verified' %}bg-success{% elif link.harvest_intake.origin_zone.verification_status == 'rejected' %}bg-danger{% else %}bg-warning text-dark{% endif %}>{{ link.harvest_intake.origin_zone.get_verification_status_display }}</span>{% else %}-{% endif %}</td>
<td>{{ link.harvest_intake.collector_group.name|default:'-' }}</td>
</tr>
{% empty %}
<tr><td colspan=6 class=text-muted>No intakes linked. Record intake lineage via API/admin.</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class=text-muted mb-0>No intake lineage recorded for this batch.</p>
{% endif %}
</div>
<div class=stat-card mb-3>
<h6 class=mb-3>Risk Assessments</h6>
{% for r in risk_assessments %}
<div class=border-bottom pb-2 mb-2>
<span class=badge {% if r.risk_level == 'high' %}bg-danger{% elif r.risk_level == 'low' %}bg-success{% else %}bg-info text-dark{% endif %}>{{ r.get_risk_level_display }}</span>
<span class=text-muted small>by {{ r.assessed_by.full_name|default:'-' }} · {{ r.assessed_at|date:'d/m/Y H:i' }}</span>
<div class=small mt-1>{{ r.basis }}</div>
</div>
{% empty %}
<p class=text-muted mb-0>No risk assessment recorded.</p>
{% endfor %}
</div>
<div class=stat-card mb-3>
<h6 class=mb-3>Mitigation Actions</h6>
{% for m in mitigation_actions %}
<div class=border-bottom pb-2 mb-2>
<span class=badge {% if m.status == 'verified' %}bg-success{% elif m.status == 'completed' %}bg-primary{% else %}bg-warning text-dark{% endif %}>{{ m.get_status_display }}</span>
<span class=small>{{ m.action }} <span class=text-muted>(owner: {{ m.owner|default:'-' }}{% if m.due_date %}, due {{ m.due_date|date:'d/m/Y' }}{% endif %})</span></span>
</div>
{% empty %}
<p class=text-muted mb-0>No mitigation actions.</p>
{% endfor %}
</div>
<div class=stat-card>
<h6 class=mb-3>External Submission (DDS) References</h6>
{% for s in statement_references %}
<div class=border-bottom pb-2 mb-2>
<span class=badge bg-secondary>{{ s.reconciliation_status }}</span>
<span class=small>Ref: <code>{{ s.dds_reference }}</code> · {{ s.submission_date|date:'d/m/Y'|default:'-' }} · by {{ s.submitted_by.full_name|default:'-' }}</span>
</div>
{% empty %}
<p class=text-muted mb-0>No external submission recorded. Submissions are manual.</p>
{% endfor %}
</div>
</div>
<div class=col-md-4>
<div class=stat-card mb-3>
<h6 class=mb-3>Case Controls</h6>
<form method=post class=mb-2>
{% csrf_token %}
<input type=hidden name=action value=check>
<button type=submit class="btn btn-outline-primary w-100"><i class=bi bi-check2-square></i> Run Completeness Check</button>
</form>
<form method=post class=mb-2>
{% csrf_token %}
<input type=hidden name=action value=assess_risk>
<button type=submit class="btn btn-outline-warning w-100"><i class=bi bi-activity></i> Run Risk Assessment</button>
</form>
<form method=post class=mb-2>
{% csrf_token %}
<input type=hidden name=action value=approve>
<button type=submit class="btn btn-success w-100"><i class=bi bi-check-circle></i> Approve Case</button>
</form>
<form method=post>
{% csrf_token %}
<input type=hidden name=action value=reject>
<label class=small>Reject reason</label>
<input type=text name=reason class="form-control form-control-sm mb-2" required>
<button type=submit class="btn btn-outline-danger w-100"><i class=bi bi-x-circle></i> Reject Case</button>
</form>
</div>
<div class=stat-card>
<h6 class=mb-3>Record External Submission</h6>
<form method=post>
{% csrf_token %}
<input type=hidden name=action value=record_submission>
<label class=small>DDS reference</label>
<input type=text name=dds_reference class="form-control form-control-sm mb-2" placeholder="e.g. DD-2026-00001" required>
<label class=small>Submission date</label>
<input type=date name=submission_date class="form-control form-control-sm mb-2">
<button type=submit class="btn btn-primary w-100"><i class=bi bi-send></i> Record Submission</button>
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,67 @@
{% extends 'ops/base_ops.html' %}
{% block title %}EUDR Compliance - IXG Operations{% endblock %}
{% block content %}
<div class=page-header d-flex justify-content-between align-items-center>
<h2>EUDR Due Diligence Cases</h2>
<span class=text-secondary>{{ page_obj.paginator.count }} total</span>
</div>
<div class=alert alert-light border py-2 small>
<i class=bi bi-info-circle></i> Shea butter is not listed in EUDR Annex I; this tracker provides due-diligence
evidence and buyer assurance, not an EU-mandated filing.
</div>
<div class=filter-bar>
<form method=get class=row g-2 align-items-end>
<div class=col-auto>
<select name=status class=form-select form-select-sm>
<option value=>All Statuses</option>
{% for val, label in status_choices %}
<option value={{ val }} {% if current_status == val %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class=col-auto>
<button type=submit class=btn btn-sm btn-primary>Filter</button>
<a href={% url 'ops:eudr_list' %} class=btn btn-sm btn-outline-secondary>Clear</a>
</div>
</form>
</div>
<div class=stat-card p-0>
<table class=table table-hover mb-0>
<thead>
<tr><th>Batch</th><th>Status</th><th>Risk Level</th><th>Approved</th><th>Updated</th><th></th></tr>
</thead>
<tbody>
{% for c in cases %}
<tr>
<td><strong>{{ c.batch.batch_reference }}</strong></td>
<td><span class=badge
{% if c.status == 'approved' %}bg-success
{% elif c.status == 'rejected' %}bg-danger
{% elif c.status == 'complete_for_review' %}bg-primary
{% elif c.status == 'mitigation_required' or c.status == 'risk_identified' %}bg-warning text-dark
{% else %}bg-secondary{% endif %}>{{ c.get_status_display }}</span></td>
<td><span class=badge {% if c.risk_level == 'high' %}bg-danger{% elif c.risk_level == 'low' %}bg-success{% else %}bg-info text-dark{% endif %}>{{ c.get_risk_level_display }}</span></td>
<td>{{ c.approved_at|date:'d/m/Y H:i'|default:'-' }}</td>
<td>{{ c.updated_at|date:'d/m/Y' }}</td>
<td><a href={% url 'ops:eudr_detail' c.pk %} class=btn btn-sm btn-primary>Review</a></td>
</tr>
{% empty %}
<tr><td colspan=6 class=text-muted text-center py-3>No due diligence cases</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.paginator.num_pages > 1 %}
<nav class=mt-3><ul class=pagination pagination-sm justify-content-center>
{% if page_obj.has_previous %}<li class=page-item><a class=page-link href=?page=1>&laquo;</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
</ul></nav>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,52 @@
{% extends 'ops/base_ops.html' %}
{% block title %}QC Verification - IXG Operations{% endblock %}
{% block content %}
<div class=page-header d-flex justify-content-between align-items-center>
<h2>QC Verification: {{ result.batch.batch_reference }}</h2>
<a href={% url 'ops:qc_verify_list' %} class=btn btn-sm btn-outline-secondary><i class=bi bi-arrow-left></i> Back</a>
</div>
{% if messages %}
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
{% endif %}
<div class=row g-3>
<div class=col-md-7>
<div class=stat-card>
<h6 class=mb-3>Structured QC Result</h6>
<table class=table table-sm table-borderless mb-0>
<tr><td class=text-secondary>Batch</td><td><a href={% url 'ops:batch_detail' result.batch.pk %}>{{ result.batch.batch_reference }}</a></td></tr>
<tr><td class=text-secondary>Overall Result</td><td>{% if result.overall_pass %}<span class=badge bg-success>Pass</span>{% else %}<span class=badge bg-danger>Fail</span>{% endif %}</td></tr>
<tr><td class=text-secondary>Specification</td><td>{{ result.spec_version|default:'legacy limits' }}</td></tr>
<tr><td class=text-secondary>FFA %</td><td>{{ result.ffa_percentage }} <span class=text-muted>({{ result.method_ffa|default:'no method' }})</span></td></tr>
<tr><td class=text-secondary>Moisture %</td><td>{{ result.moisture_percentage }} <span class=text-muted>({{ result.method_moisture|default:'no method' }})</span></td></tr>
<tr><td class=text-secondary>Peroxide Value</td><td>{{ result.peroxide_value }} <span class=text-muted>({{ result.method_peroxide|default:'no method' }})</span></td></tr>
<tr><td class=text-secondary>Visual Grade</td><td>{{ result.visual_grade }}</td></tr>
<tr><td class=text-secondary>Result Status</td><td>{{ result.get_result_status_display }}</td></tr>
<tr><td class=text-secondary>Lab Reference</td><td>{{ result.lab_reference|default:'-' }}</td></tr>
<tr><td class=text-secondary>Tested By</td><td>{{ result.tested_by|default:'-' }}</td></tr>
<tr><td class=text-secondary>Entered By</td><td>{{ result.entered_by.full_name|default:result.entered_by.email|default:'-' }}</td></tr>
<tr><td class=text-secondary>Tested At</td><td>{{ result.tested_at|date:'d/m/Y H:i' }}</td></tr>
</table>
</div>
</div>
<div class=col-md-5>
<div class=stat-card>
<h6 class=mb-3>Approval Decision</h6>
{% if result.verification_status == 'draft' %}
<form method=post>
{% csrf_token %}
<p class=text-muted>This action is recorded as the quality approval. The submitter cannot approve their own result.</p>
<button type=submit name=approved value=1 class="btn btn-success w-100 mb-2"><i class=bi bi-check-circle></i> Approve &amp; Generate CoA</button>
<button type=submit name=approved value=0 class="btn btn-outline-danger w-100"><i class=bi bi-x-circle></i> Reject</button>
</form>
{% else %}
<p><span class=badge {% if result.verification_status == 'approved' %}bg-success{% else %}bg-danger{% endif %}>{{ result.get_verification_status_display }}</span></p>
{% if result.verified_by %}<p class=text-muted mb-0>Verified by {{ result.verified_by.full_name|default:result.verified_by.email }} on {{ result.approved_at|date:'d/m/Y H:i' }}</p>{% endif %}
{% endif %}
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,59 @@
{% extends 'ops/base_ops.html' %}
{% block title %}QC Approvals - IXG Operations{% endblock %}
{% block content %}
<div class=page-header d-flex justify-content-between align-items-center>
<h2>QC Approvals (four-eyes verification)</h2>
<span class=text-secondary>{{ page_obj.paginator.count }} pending</span>
</div>
<div class=filter-bar>
<form method=get class=row g-2 align-items-end>
<div class=col-auto>
<select name=passed class=form-select form-select-sm>
<option value=>All Results</option>
<option value=1 {% if current_passed == '1' %}selected{% endif %}>Passed</option>
<option value=0 {% if current_passed == '0' %}selected{% endif %}>Failed</option>
</select>
</div>
<div class=col-auto>
<button type=submit class=btn btn-sm btn-primary>Filter</button>
<a href={% url 'ops:qc_verify_list' %} class=btn btn-sm btn-outline-secondary>Clear</a>
</div>
</form>
</div>
<div class=stat-card p-0>
<table class=table table-hover mb-0>
<thead>
<tr><th>Batch</th><th>Result</th><th>FFA%</th><th>Moisture%</th><th>Peroxide</th><th>Visual</th><th>Entered By</th><th>Spec</th><th></th></tr>
</thead>
<tbody>
{% for r in results %}
<tr>
<td><strong>{{ r.batch.batch_reference }}</strong></td>
<td>{% if r.overall_pass %}<span class=badge bg-success>Pass</span>{% else %}<span class=badge bg-danger>Fail</span>{% endif %}</td>
<td>{{ r.ffa_percentage }}</td>
<td>{{ r.moisture_percentage }}</td>
<td>{{ r.peroxide_value }}</td>
<td>{{ r.visual_grade }}</td>
<td>{{ r.entered_by.full_name|default:r.entered_by.email|default:'-' }}</td>
<td>{{ r.spec_version|default:'-' }}</td>
<td><a href={% url 'ops:qc_verify_detail' r.pk %} class=btn btn-sm btn-primary>Verify</a></td>
</tr>
{% empty %}
<tr><td colspan=9 class=text-muted text-center py-3>No QC results awaiting verification</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.paginator.num_pages > 1 %}
<nav class=mt-3><ul class=pagination pagination-sm justify-content-center>
{% if page_obj.has_previous %}<li class=page-item><a class=page-link href=?page=1>&laquo;</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
</ul></nav>
{% endif %}
{% endblock %}

View file

@ -33,6 +33,7 @@ body { font-size: .875rem; background: #f0fdf4; }
<li class="nav-item"><a class="nav-link {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}" href="{% url 'portal:dashboard' %}"><i class="bi bi-speedometer2"></i> Dashboard</a></li>
<li class="nav-item"><a class="nav-link {% if 'batch' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'portal:batch_list' %}"><i class="bi bi-box-seam"></i> Batches</a></li>
<li class="nav-item"><a class="nav-link {% if 'document' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'portal:document_list' %}"><i class="bi bi-file-earmark-text"></i> Documents</a></li>
<li class="nav-item"><a class="nav-link {% if 'compliance' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'portal:compliance' %}"><i class="bi bi-shield-check"></i> Compliance</a></li>
<li class="nav-item"><a class="nav-link {% if 'subscription' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'portal:subscription' %}"><i class="bi bi-credit-card"></i> Subscription</a></li>
<li class="nav-item"><a class="nav-link {% if 'chat' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'portal:chat' %}"><i class="bi bi-robot"></i> AI Chat</a></li>
</ul>

View file

@ -0,0 +1,62 @@
{% extends 'portal/base_portal.html' %}
{% block title %}Compliance - IXG Buyer Portal{% endblock %}
{% block content %}
<div class="page-header d-flex justify-content-between align-items-center">
<h2>Compliance</h2>
<span class="text-secondary">Approved evidence for your batches</span>
</div>
<div class="alert alert-light border py-2 small">
<i class="bi bi-info-circle"></i> Shea butter is not listed in EUDR Annex I; the due-diligence status shown here is
evidence and buyer assurance provided by IXG, not an EU-mandated filing.
</div>
<div class="row g-3">
<div class="col-md-7">
<div class="stat-card">
<h6 class="mb-3"><i class="bi bi-file-earmark-medical"></i> Certificates of Analysis (approved)</h6>
<table class="table table-sm mb-0">
<thead><tr><th>Batch</th><th>Product</th><th>Issued</th><th>Download</th></tr></thead>
<tbody>
{% for c in approved_coas %}
<tr>
<td><strong>{{ c.batch.batch_reference }}</strong></td>
<td>Unrefined Shea Butter</td>
<td>{{ c.issue_date|date:'d/m/Y'|default:'-' }}</td>
<td><a href="{{ c.file_url }}" target="_blank" class="btn btn-sm btn-outline-success"><i class="bi bi-download"></i> PDF</a></td>
</tr>
{% empty %}
<tr><td colspan="4" class="text-muted text-center py-3">No approved certificates available</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="col-md-5">
<div class="stat-card">
<h6 class="mb-3"><i class="bi bi-shield-check"></i> EUDR Due Diligence Status</h6>
<table class="table table-sm mb-0">
<thead><tr><th>Batch</th><th>Status</th><th>Risk</th></tr></thead>
<tbody>
{% for c in eudr_cases %}
<tr>
<td><strong>{{ c.batch.batch_reference }}</strong></td>
<td><span class="badge
{% if c.status == 'approved' %}bg-success
{% elif c.status == 'rejected' %}bg-danger
{% elif c.status == 'complete_for_review' %}bg-primary
{% elif c.status == 'mitigation_required' or c.status == 'risk_identified' %}bg-warning text-dark
{% else %}bg-secondary{% endif %}">{{ c.get_status_display }}</span></td>
<td><span class="badge {% if c.risk_level == 'high' %}bg-danger{% elif c.risk_level == 'low' %}bg-success{% else %}bg-info text-dark{% endif %}">{{ c.get_risk_level_display }}</span></td>
</tr>
{% empty %}
<tr><td colspan="3" class="text-muted text-center py-3">No due-diligence cases recorded</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}