Compare commits
30 commits
08096e168f
...
ef6f217e45
| Author | SHA1 | Date | |
|---|---|---|---|
| ef6f217e45 | |||
| df2a90bc71 | |||
| f6aedc0002 | |||
| 243d4eefcd | |||
| 8c8e7908fd | |||
| 19d67555da | |||
| 5b6c4c48e4 | |||
| dae025f600 | |||
| 5e201b2035 | |||
| b376c30300 | |||
| 8ad83a7a65 | |||
| fa9295d15a | |||
| be2da9c6e4 | |||
| b4d6d4bb20 | |||
| 8d209f3cbf | |||
| 30d07dba41 | |||
| 88eede5d7a | |||
| 4b4477d359 | |||
| 6a3a902045 | |||
| e2a6784505 | |||
| db05d6052a | |||
| 7d7248a23b | |||
| d5bc06802e | |||
| cdefbd010c | |||
| 5d0b5fe757 | |||
| 4667637265 | |||
| c568a34dc0 | |||
| ee4f7b5232 | |||
| 8f1649aaa1 | |||
| 6508cc25ef |
112 changed files with 10635 additions and 557 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -10,3 +10,9 @@ media/
|
|||
dist/
|
||||
build/
|
||||
.DS_Store
|
||||
|
||||
# Runtime state and stray local backups — never part of the source tree
|
||||
celerybeat-schedule
|
||||
*.bak*
|
||||
*.orig
|
||||
*.rej
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from rest_framework import viewsets, permissions, status
|
|||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.compliance.permissions import IsOpsOrAdmin
|
||||
|
||||
from .models import AIInteraction, AgentConfig
|
||||
from .serializers import (
|
||||
AIInteractionSerializer, ChatRequestSerializer,
|
||||
|
|
@ -100,8 +102,17 @@ class AIInteractionViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
|
||||
class AgentConfigViewSet(viewsets.ModelViewSet):
|
||||
queryset = AgentConfig.objects.all()
|
||||
# The buyer chat reads the agent it talks to — templates/portal/chat_embed.html
|
||||
# fetches /api/agents/<id>/ — so reads stay open to an authenticated user,
|
||||
# scoped by get_queryset to their own or the shared agents. Only ops/admin
|
||||
# may create or change one.
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ("create", "update", "partial_update", "destroy"):
|
||||
return [IsOpsOrAdmin()]
|
||||
return super().get_permissions()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.request.user.role in ("admin", "ops") or self.request.user.is_superuser:
|
||||
return AgentConfigWriteSerializer
|
||||
|
|
|
|||
|
|
@ -49,8 +49,37 @@ class ProcessingBatchDetailSerializer(serializers.ModelSerializer):
|
|||
return DocumentSerializer(docs, many=True).data
|
||||
|
||||
|
||||
# Mirrors apps/ops/forms.py: a QC status is set by recording a QC result, not by
|
||||
# hand, and a failed batch is sticky — only a new result moves it. The portal
|
||||
# enforces this in BatchForm; without the same rule here the write serializer is
|
||||
# a way around the screen.
|
||||
QC_SET_STATUSES = {
|
||||
ProcessingBatch.Status.QC_PASS,
|
||||
ProcessingBatch.Status.QC_FAIL,
|
||||
}
|
||||
|
||||
|
||||
class ProcessingBatchWriteSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProcessingBatch
|
||||
fields = ["id", "batch_reference", "harvest_date", "weight_kg", "grade", "origin_facility", "notes", "status"]
|
||||
read_only_fields = ["id"]
|
||||
|
||||
def validate_status(self, status):
|
||||
instance = self.instance
|
||||
unchanged = instance is not None and status == instance.status
|
||||
if status in QC_SET_STATUSES and not unchanged:
|
||||
raise serializers.ValidationError(
|
||||
f"Batch status {ProcessingBatch.Status(status).label} is set by the "
|
||||
"QC result, not by hand."
|
||||
)
|
||||
if (
|
||||
instance is not None
|
||||
and instance.status == ProcessingBatch.Status.QC_FAIL
|
||||
and status != ProcessingBatch.Status.QC_FAIL
|
||||
):
|
||||
raise serializers.ValidationError(
|
||||
"A batch that has failed QC cannot be moved by hand; a new QC "
|
||||
"result is what moves it."
|
||||
)
|
||||
return status
|
||||
|
|
|
|||
92
apps/batches/tests.py
Normal file
92
apps/batches/tests.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""The processing-batch API.
|
||||
|
||||
The portal screens have their own tests; these pin the API boundary that used to
|
||||
be missing. ``ProcessingBatchViewSet`` was open to any authenticated user and the
|
||||
write serializer accepted ``status``, so a buyer could PATCH a failed batch to
|
||||
``delivered``. The write rules mirror ``apps/ops/forms.py``: a QC status is set by
|
||||
a QC result, and a failed batch is sticky.
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from .models import ProcessingBatch
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ProcessingBatchAPITests(TestCase):
|
||||
def setUp(self):
|
||||
self.ops = User.objects.create_user(
|
||||
email="batch-api-ops@ixg.local", password="pass1234", role="ops",
|
||||
full_name="Batch API Ops", is_staff=True,
|
||||
)
|
||||
self.buyer = User.objects.create_user(
|
||||
email="batch-api-buyer@ixg.local", password="pass1234", role="buyer",
|
||||
full_name="Batch API Buyer", is_staff=True,
|
||||
)
|
||||
self.failed = ProcessingBatch.objects.create(
|
||||
batch_reference="IXG-BATCH-API-001",
|
||||
status=ProcessingBatch.Status.QC_FAIL,
|
||||
)
|
||||
self.client = APIClient()
|
||||
|
||||
def test_a_buyer_cannot_patch_a_batch(self):
|
||||
self.client.force_authenticate(user=self.buyer)
|
||||
response = self.client.patch(
|
||||
f"/api/batches/{self.failed.id}/", {"status": "delivered"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.failed.refresh_from_db()
|
||||
self.assertEqual(self.failed.status, ProcessingBatch.Status.QC_FAIL)
|
||||
|
||||
def test_a_buyer_cannot_read_the_batch_api(self):
|
||||
"""The buyer portal reads batches through the ORM, not this API, so the
|
||||
API needs no buyer read and is closed to one."""
|
||||
self.client.force_authenticate(user=self.buyer)
|
||||
self.assertEqual(self.client.get("/api/batches/").status_code, 403)
|
||||
|
||||
def test_ops_can_read_the_batch_api(self):
|
||||
self.client.force_authenticate(user=self.ops)
|
||||
self.assertEqual(self.client.get("/api/batches/").status_code, 200)
|
||||
|
||||
def test_a_qc_status_cannot_be_hand_set(self):
|
||||
self.client.force_authenticate(user=self.ops)
|
||||
journey = ProcessingBatch.objects.create(
|
||||
batch_reference="IXG-BATCH-API-002",
|
||||
status=ProcessingBatch.Status.HARVESTED,
|
||||
)
|
||||
response = self.client.patch(
|
||||
f"/api/batches/{journey.id}/", {"status": "qc_pass"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
journey.refresh_from_db()
|
||||
self.assertEqual(journey.status, ProcessingBatch.Status.HARVESTED)
|
||||
|
||||
def test_a_batch_cannot_be_created_with_a_qc_status(self):
|
||||
self.client.force_authenticate(user=self.ops)
|
||||
response = self.client.post(
|
||||
"/api/batches/",
|
||||
{"batch_reference": "IXG-BATCH-API-003", "status": "qc_pass"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(
|
||||
ProcessingBatch.objects.filter(batch_reference="IXG-BATCH-API-003").exists()
|
||||
)
|
||||
|
||||
def test_a_failed_batch_cannot_be_moved_out_of_qc_fail(self):
|
||||
self.client.force_authenticate(user=self.ops)
|
||||
response = self.client.patch(
|
||||
f"/api/batches/{self.failed.id}/", {"status": "delivered"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.failed.refresh_from_db()
|
||||
self.assertEqual(self.failed.status, ProcessingBatch.Status.QC_FAIL)
|
||||
|
||||
def test_a_failed_batch_can_be_saved_with_its_own_status(self):
|
||||
self.client.force_authenticate(user=self.ops)
|
||||
response = self.client.patch(
|
||||
f"/api/batches/{self.failed.id}/", {"status": "qc_fail"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
|
@ -1,12 +1,18 @@
|
|||
from rest_framework import viewsets, permissions, filters
|
||||
from rest_framework import viewsets, filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
from apps.compliance.permissions import IsOpsOrAdmin
|
||||
|
||||
from .models import ProcessingBatch
|
||||
from .serializers import ProcessingBatchListSerializer, ProcessingBatchDetailSerializer, ProcessingBatchWriteSerializer
|
||||
|
||||
|
||||
class ProcessingBatchViewSet(viewsets.ModelViewSet):
|
||||
queryset = ProcessingBatch.objects.prefetch_related("qc_results", "shipments", "documents").all()
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
# The buyer portal reads batches through the ORM (apps/buyer/views.py), not
|
||||
# this API, so nothing outside ops/admin needs it — including for reads. That
|
||||
# closes the hole where a buyer could PATCH a batch's status.
|
||||
permission_classes = [IsOpsOrAdmin]
|
||||
filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
|
||||
filterset_fields = ["status", "grade"]
|
||||
search_fields = ["batch_reference"]
|
||||
|
|
@ -22,10 +28,3 @@ class ProcessingBatchViewSet(viewsets.ModelViewSet):
|
|||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(created_by=self.request.user)
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = super().get_queryset()
|
||||
if user.role == "buyer":
|
||||
return qs # Buyers can read all batches per spec
|
||||
return qs
|
||||
|
|
|
|||
69
apps/buyer/forms.py
Normal file
69
apps/buyer/forms.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""The public buyer sign-up form.
|
||||
|
||||
This is the only place on the platform a password minimum is enforced — the
|
||||
realm has no password policy set and no mail service exists — so the rules live
|
||||
here and are deliberately plain: long enough, not the email or the company name,
|
||||
and typed twice the same. The form is also the first line of duplicate-email
|
||||
handling; Keycloak refuses a second account with the same address, and the view
|
||||
turns that into the same sentence.
|
||||
"""
|
||||
from django import forms
|
||||
|
||||
from apps.users.models import User
|
||||
|
||||
MIN_PASSWORD_LENGTH = 12
|
||||
|
||||
DUPLICATE_EMAIL_MESSAGE = "An account with this email already exists — sign in instead."
|
||||
|
||||
|
||||
class BuyerSignupForm(forms.Form):
|
||||
full_name = forms.CharField(
|
||||
max_length=255,
|
||||
label="Full name",
|
||||
widget=forms.TextInput(attrs={"class": "form-control", "autocomplete": "name"}),
|
||||
)
|
||||
company_name = forms.CharField(
|
||||
max_length=255,
|
||||
label="Company name",
|
||||
widget=forms.TextInput(attrs={"class": "form-control", "autocomplete": "organization"}),
|
||||
)
|
||||
email = forms.EmailField(
|
||||
label="Email",
|
||||
widget=forms.EmailInput(attrs={"class": "form-control", "autocomplete": "email"}),
|
||||
)
|
||||
password = forms.CharField(
|
||||
label="Password",
|
||||
widget=forms.PasswordInput(attrs={"class": "form-control", "autocomplete": "new-password"}),
|
||||
help_text=f"At least {MIN_PASSWORD_LENGTH} characters.",
|
||||
)
|
||||
confirm_password = forms.CharField(
|
||||
label="Confirm password",
|
||||
widget=forms.PasswordInput(attrs={"class": "form-control", "autocomplete": "new-password"}),
|
||||
)
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data["email"].strip().lower()
|
||||
if User.objects.filter(email__iexact=email).exists():
|
||||
raise forms.ValidationError(DUPLICATE_EMAIL_MESSAGE)
|
||||
return email
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
password = cleaned.get("password")
|
||||
confirm = cleaned.get("confirm_password")
|
||||
if password and confirm and password != confirm:
|
||||
self.add_error("confirm_password", "The two passwords do not match.")
|
||||
if password:
|
||||
if len(password) < MIN_PASSWORD_LENGTH:
|
||||
self.add_error(
|
||||
"password",
|
||||
f"Use at least {MIN_PASSWORD_LENGTH} characters.",
|
||||
)
|
||||
email = (cleaned.get("email") or "").strip().lower()
|
||||
company = (cleaned.get("company_name") or "").strip().lower()
|
||||
if password.lower() in (email, company) and password:
|
||||
self.add_error(
|
||||
"password",
|
||||
"Choose a password that is not your email address or company name.",
|
||||
)
|
||||
return cleaned
|
||||
122
apps/buyer/tests.py
Normal file
122
apps/buyer/tests.py
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"""The public buyer sign-up screen.
|
||||
|
||||
Two things matter: the page is reachable without signing in, and the account it
|
||||
creates is a plain buyer — the same kind of account everything else uses, with
|
||||
no platform privileges. Keycloak is patched here; the live Admin API path is
|
||||
exercised by the smoke test, not by the suite.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.users.keycloak import DuplicateEmailError, KeycloakUnavailableError
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
SIGNUP_URL = "/portal/signup/"
|
||||
|
||||
VALID = {
|
||||
"full_name": "Ada Greenfield",
|
||||
"company_name": "Greenfield Foods Ltd",
|
||||
"email": "signup-smoke-01@example.com",
|
||||
"password": "a-long-enough-passphrase",
|
||||
"confirm_password": "a-long-enough-passphrase",
|
||||
}
|
||||
|
||||
|
||||
class SignupViewTests(TestCase):
|
||||
def test_the_page_is_public(self):
|
||||
response = self.client.get(SIGNUP_URL)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "Create your buyer account")
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0001")
|
||||
def test_successful_signup_creates_both_records(self, create):
|
||||
response = self.client.post(SIGNUP_URL, VALID)
|
||||
|
||||
expected = reverse("oidc_authentication_init") + "?" + urlencode({"next": "/portal/?welcome=1"})
|
||||
self.assertRedirects(response, expected, fetch_redirect_response=False)
|
||||
create.assert_called_once_with(
|
||||
email=VALID["email"], full_name=VALID["full_name"], password=VALID["password"]
|
||||
)
|
||||
|
||||
user = User.objects.get(email=VALID["email"])
|
||||
self.assertEqual(user.role, "buyer")
|
||||
self.assertFalse(user.is_staff)
|
||||
self.assertTrue(user.is_active)
|
||||
self.assertEqual(user.company_name, "Greenfield Foods Ltd")
|
||||
self.assertEqual(user.keycloak_id, "kc-sub-0001")
|
||||
|
||||
def test_a_duplicate_email_is_refused_without_calling_keycloak(self):
|
||||
User.objects.create_user(email=VALID["email"], role="buyer")
|
||||
with patch("apps.buyer.views.create_keycloak_user") as create:
|
||||
response = self.client.post(SIGNUP_URL, VALID)
|
||||
create.assert_not_called()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "An account with this email already exists")
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", side_effect=DuplicateEmailError("x"))
|
||||
def test_a_keycloak_duplicate_is_refused_the_same_way(self, create):
|
||||
response = self.client.post(SIGNUP_URL, VALID)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "An account with this email already exists")
|
||||
self.assertFalse(User.objects.filter(email=VALID["email"]).exists())
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", side_effect=KeycloakUnavailableError("down"))
|
||||
def test_an_unreachable_keycloak_is_reported_not_a_500(self, create):
|
||||
response = self.client.post(SIGNUP_URL, VALID)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "could not create your account")
|
||||
self.assertFalse(User.objects.filter(email=VALID["email"]).exists())
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0002")
|
||||
def test_mismatched_confirmation_is_refused(self, create):
|
||||
response = self.client.post(SIGNUP_URL, {**VALID, "confirm_password": "something-else-entirely"})
|
||||
create.assert_not_called()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "The two passwords do not match")
|
||||
self.assertFalse(User.objects.filter(email=VALID["email"]).exists())
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0003")
|
||||
def test_a_short_password_is_refused(self, create):
|
||||
response = self.client.post(SIGNUP_URL, {**VALID, "password": "short", "confirm_password": "short"})
|
||||
create.assert_not_called()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "at least 12 characters")
|
||||
|
||||
@patch("apps.buyer.views.create_keycloak_user", return_value="kc-sub-0004")
|
||||
def test_a_password_equal_to_the_email_is_refused(self, create):
|
||||
password = VALID["email"]
|
||||
response = self.client.post(
|
||||
SIGNUP_URL, {**VALID, "password": password, "confirm_password": password}
|
||||
)
|
||||
create.assert_not_called()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "not your email address or company name")
|
||||
|
||||
def test_a_signed_in_user_is_sent_to_their_portal(self):
|
||||
User.objects.create_user(email="already-in@example.com", role="buyer", is_staff=False)
|
||||
self.client.force_login(User.objects.get(email="already-in@example.com"))
|
||||
response = self.client.get(SIGNUP_URL)
|
||||
self.assertRedirects(response, "/", fetch_redirect_response=False)
|
||||
|
||||
|
||||
class DashboardWelcomeTests(TestCase):
|
||||
def test_the_welcome_message_shows_after_signup(self):
|
||||
User.objects.create_user(
|
||||
email="welcome-buyer@example.com", role="buyer", is_staff=False
|
||||
)
|
||||
self.client.force_login(User.objects.get(email="welcome-buyer@example.com"))
|
||||
response = self.client.get("/portal/?welcome=1")
|
||||
self.assertContains(response, "Your account is ready")
|
||||
|
||||
def test_no_welcome_message_on_a_normal_visit(self):
|
||||
User.objects.create_user(
|
||||
email="quiet-buyer@example.com", role="buyer", is_staff=False
|
||||
)
|
||||
self.client.force_login(User.objects.get(email="quiet-buyer@example.com"))
|
||||
response = self.client.get("/portal/")
|
||||
self.assertNotContains(response, "Your account is ready")
|
||||
|
|
@ -4,12 +4,17 @@ from . import views
|
|||
app_name = 'portal'
|
||||
|
||||
urlpatterns = [
|
||||
path("signup/", views.SignupView.as_view(), name="signup"),
|
||||
path("chat/", views.ChatView.as_view(), name="chat"),
|
||||
path("chat/embed/", views.ChatEmbedView.as_view(), name="chat_embed"),
|
||||
path('', views.DashboardView.as_view(), name='dashboard'),
|
||||
path('batches/', views.BatchListView.as_view(), name='batch_list'),
|
||||
path('batches/<uuid:pk>/', views.BatchDetailView.as_view(), name='batch_detail'),
|
||||
path('documents/', views.DocumentListView.as_view(), name='document_list'),
|
||||
path('documents/<uuid:pk>/download/', views.DocumentDownloadView.as_view(), name='document_download'),
|
||||
path('certificates/<uuid:pk>/download/', views.CoADownloadView.as_view(), name='coa_download'),
|
||||
path('subscription/', views.SubscriptionView.as_view(), name='subscription'),
|
||||
path('compliance/', views.ComplianceView.as_view(), name='compliance'),
|
||||
path('help/', views.GuideIndexView.as_view(), name='guide_index'),
|
||||
path('help/<path:page_path>/', views.GuideDetailView.as_view(), name='guide_detail'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,12 +1,28 @@
|
|||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.db.models import Count
|
||||
from django.views.generic import TemplateView, ListView, DetailView
|
||||
from django.shortcuts import get_object_or_404, redirect
|
||||
from django.urls import reverse
|
||||
from django.views import View
|
||||
from django.views.generic import TemplateView, ListView, DetailView, FormView
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
from apps.shipments.models import Shipment
|
||||
from apps.documents.models import Document
|
||||
from apps.documents.downloads import file_response
|
||||
from apps.documents.models import CertificateOfAnalysis, Document
|
||||
from apps.esg.models import ESGFieldReport
|
||||
from apps.subscriptions.models import Subscription
|
||||
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
||||
from apps.guides.views import GuideIndexView as BaseGuideIndexView
|
||||
from apps.users.keycloak import (
|
||||
DuplicateEmailError,
|
||||
KeycloakError,
|
||||
create_keycloak_user,
|
||||
)
|
||||
from apps.users.models import User
|
||||
|
||||
from .forms import DUPLICATE_EMAIL_MESSAGE, BuyerSignupForm
|
||||
|
||||
|
||||
class BuyerRequiredMixin(UserPassesTestMixin):
|
||||
|
|
@ -15,11 +31,64 @@ class BuyerRequiredMixin(UserPassesTestMixin):
|
|||
return user.is_authenticated and (user.role in ("buyer", "admin") or user.is_superuser)
|
||||
|
||||
|
||||
class SignupView(FormView):
|
||||
"""Public buyer self-registration.
|
||||
|
||||
Creates the Keycloak user first — the Django row stores its subject id —
|
||||
then the matching ``users`` row as a buyer, and sends the new buyer through
|
||||
the normal sign-in flow. There is one sign-in mechanism platform-wide; the
|
||||
sign-up screen only creates the account.
|
||||
|
||||
A duplicate is refused with the same sentence whether Django or Keycloak
|
||||
spots it, and an unreachable Keycloak is reported, never a 500.
|
||||
"""
|
||||
|
||||
template_name = "portal/signup.html"
|
||||
form_class = BuyerSignupForm
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
if request.user.is_authenticated:
|
||||
return redirect("home")
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def form_valid(self, form):
|
||||
data = form.cleaned_data
|
||||
try:
|
||||
keycloak_id = create_keycloak_user(
|
||||
email=data["email"],
|
||||
full_name=data["full_name"],
|
||||
password=data["password"],
|
||||
)
|
||||
except DuplicateEmailError:
|
||||
form.add_error("email", DUPLICATE_EMAIL_MESSAGE)
|
||||
return self.form_invalid(form)
|
||||
except KeycloakError:
|
||||
form.add_error(
|
||||
None,
|
||||
"We could not create your account just now. Please try again shortly.",
|
||||
)
|
||||
return self.form_invalid(form)
|
||||
|
||||
User.objects.create_user(
|
||||
email=data["email"],
|
||||
full_name=data["full_name"],
|
||||
company_name=data["company_name"],
|
||||
role=User.Roles.BUYER,
|
||||
is_active=True,
|
||||
is_staff=False,
|
||||
keycloak_id=keycloak_id,
|
||||
)
|
||||
|
||||
sign_in = reverse("oidc_authentication_init")
|
||||
return redirect(f"{sign_in}?{urlencode({'next': '/portal/?welcome=1'})}")
|
||||
|
||||
|
||||
class DashboardView(BuyerRequiredMixin, TemplateView):
|
||||
template_name = "portal/dashboard.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["welcome"] = self.request.GET.get("welcome") == "1"
|
||||
ctx["total_batches"] = ProcessingBatch.objects.count()
|
||||
ctx["recent_batches"] = ProcessingBatch.objects.order_by("-created_at")[:5]
|
||||
ctx["documents"] = Document.objects.filter(visibility="buyer").order_by("-uploaded_at")[:5]
|
||||
|
|
@ -86,6 +155,35 @@ class DocumentListView(BuyerRequiredMixin, ListView):
|
|||
return ctx
|
||||
|
||||
|
||||
class DocumentDownloadView(BuyerRequiredMixin, View):
|
||||
"""Serve the stored file behind a buyer-visible document.
|
||||
|
||||
A buyer may only fetch files from documents marked buyer-visible, so a
|
||||
guessed document id belonging to an operations-only row is a 404 rather
|
||||
than a leak.
|
||||
"""
|
||||
|
||||
def get(self, request, pk):
|
||||
doc = get_object_or_404(Document, pk=pk, visibility=Document.Visibility.BUYER)
|
||||
return file_response(doc.file_url, doc.file_name)
|
||||
|
||||
|
||||
class CoADownloadView(BuyerRequiredMixin, View):
|
||||
"""Serve the summary PDF of a published certificate of analysis.
|
||||
|
||||
Only a published certificate is downloadable, matching the compliance page
|
||||
that links to it: a certificate still awaiting release stays internal.
|
||||
"""
|
||||
|
||||
def get(self, request, pk):
|
||||
coa = get_object_or_404(
|
||||
CertificateOfAnalysis,
|
||||
pk=pk,
|
||||
status=CertificateOfAnalysis.Status.PUBLISHED,
|
||||
)
|
||||
return file_response(coa.file_url, coa.file_name)
|
||||
|
||||
|
||||
class SubscriptionView(BuyerRequiredMixin, DetailView):
|
||||
model = Subscription
|
||||
template_name = "portal/subscription.html"
|
||||
|
|
@ -130,3 +228,11 @@ class ComplianceView(BuyerRequiredMixin, TemplateView):
|
|||
).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
|
||||
|
||||
|
||||
class GuideIndexView(BuyerRequiredMixin, BaseGuideIndexView):
|
||||
template_name = "guides/index_portal.html"
|
||||
|
||||
|
||||
class GuideDetailView(BuyerRequiredMixin, BaseGuideDetailView):
|
||||
template_name = "guides/detail_portal.html"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
from rest_framework import viewsets, permissions
|
||||
from rest_framework import viewsets
|
||||
|
||||
from apps.compliance.permissions import IsOpsOrAdmin
|
||||
|
||||
from .models import CAPARecord
|
||||
from .serializers import CAPARecordSerializer
|
||||
|
||||
|
|
@ -6,4 +9,7 @@ from .serializers import CAPARecordSerializer
|
|||
class CAPARecordViewSet(viewsets.ModelViewSet):
|
||||
queryset = CAPARecord.objects.all()
|
||||
serializer_class = CAPARecordSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
# Corrective actions are an internal ops record — the buyer portal does not
|
||||
# read them at all, through the ORM or this API — so both read and write are
|
||||
# closed to a buyer.
|
||||
permission_classes = [IsOpsOrAdmin]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import json
|
||||
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
|
||||
from apps.core.tiles import browser_tile_url
|
||||
|
||||
from .models import (
|
||||
ProductSpecification, SpecParameter,
|
||||
|
|
@ -29,6 +34,44 @@ class FacilityAdmin(admin.ModelAdmin):
|
|||
class OriginZoneAdmin(admin.ModelAdmin):
|
||||
list_display = ["zone_code", "facility", "country", "region", "verification_status", "verified_at"]
|
||||
list_filter = ["verification_status", "country"]
|
||||
readonly_fields = ["zone_map"]
|
||||
|
||||
class Media:
|
||||
css = {"all": ("vendor/leaflet/leaflet.css",)}
|
||||
js = ("vendor/leaflet/leaflet.js", "js/ixg_zone_map.js")
|
||||
|
||||
@admin.display(description="Area on a map")
|
||||
def zone_map(self, obj):
|
||||
"""Draw the stored polygon, so the shape can be checked by eye.
|
||||
|
||||
Display only. It does not validate the geometry and it does not verify
|
||||
the zone — those are separate pieces of work.
|
||||
|
||||
The tile URL is the same-origin path from apps/core/tiles.py, so the map
|
||||
keeps working when a viewer's network cannot reach a third-party tile
|
||||
server.
|
||||
"""
|
||||
if obj is None:
|
||||
return "Save this zone, then its area can be drawn here."
|
||||
|
||||
if not obj.geometry or not obj.geometry.get("coordinates"):
|
||||
return format_html(
|
||||
'<div style="max-width:900px;padding:1rem;border:1px dashed #cbd5e1;'
|
||||
'border-radius:.375rem;color:#64748b">No polygon coordinates are stored '
|
||||
'for this zone, so there is nothing to draw. Coordinates are captured in '
|
||||
'the field, then entered on the Geometry field.</div>'
|
||||
)
|
||||
|
||||
return format_html(
|
||||
'<div id="zone-map" data-geometry="{}" data-tile-url="{}" '
|
||||
'style="height:420px;max-width:900px;border:1px solid #cbd5e1;'
|
||||
'border-radius:.375rem;background:#eef2f5"></div>'
|
||||
'<p style="max-width:900px;margin-top:.5rem;font-size:.8rem;color:#64748b">'
|
||||
"Drawn from the stored polygon. A view, not a check: it does not validate the "
|
||||
"geometry and it does not verify the zone.</p>",
|
||||
json.dumps(obj.geometry),
|
||||
browser_tile_url(),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(CollectorGroup)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from . import services
|
||||
from .models import (
|
||||
ProductSpecification, SpecParameter,
|
||||
Facility, OriginZone, CollectorGroup, HarvestIntake, IntakeBatchLineage,
|
||||
|
|
@ -68,6 +69,21 @@ class IntakeBatchLineageSerializer(serializers.ModelSerializer):
|
|||
fields = "__all__"
|
||||
read_only_fields = ["id", "created_at"]
|
||||
|
||||
def validate(self, attrs):
|
||||
intake = attrs.get("harvest_intake") or getattr(
|
||||
self.instance, "harvest_intake", None
|
||||
)
|
||||
weight = attrs.get("weight_allocated_kg")
|
||||
if weight is None and self.instance is not None:
|
||||
weight = self.instance.weight_allocated_kg
|
||||
if intake is not None and weight is not None:
|
||||
message = services.intake_allocation_error(
|
||||
intake, weight, exclude_lineage=self.instance
|
||||
)
|
||||
if message:
|
||||
raise serializers.ValidationError({"weight_allocated_kg": message})
|
||||
return attrs
|
||||
|
||||
|
||||
class EUDREvidenceSerializer(serializers.ModelSerializer):
|
||||
verified_by_name = serializers.CharField(source="verified_by.full_name", read_only=True)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from decimal import Decimal
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Sum
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
|
|
@ -177,6 +178,43 @@ def ensure_due_diligence_case(batch):
|
|||
return case
|
||||
|
||||
|
||||
def intake_allocation_error(intake, weight, *, exclude_batch=None, exclude_lineage=None):
|
||||
"""Return a message when ``weight`` cannot be allocated from ``intake``.
|
||||
|
||||
An intake is a finite quantity of shea, so the rule counts **every** batch
|
||||
that has already drawn from it, not just the one being saved. ``exclude_batch``
|
||||
and ``exclude_lineage`` keep the caller's own allocation out of that total.
|
||||
Shared by the ops lineage formset and the lineage API serializer so the screen
|
||||
and the API refuse the same allocations.
|
||||
"""
|
||||
from apps.compliance.models import IntakeBatchLineage
|
||||
|
||||
received = intake.weight_kg
|
||||
if received is None:
|
||||
# Records that arrived through the API can be missing their weight.
|
||||
# Nothing to check against, so nothing is claimed.
|
||||
return None
|
||||
if weight > received:
|
||||
return (
|
||||
f"{intake.intake_reference} received {received} kg; "
|
||||
f"{weight} kg cannot be allocated from it."
|
||||
)
|
||||
elsewhere_qs = IntakeBatchLineage.objects.filter(harvest_intake=intake)
|
||||
if exclude_batch is not None:
|
||||
elsewhere_qs = elsewhere_qs.exclude(batch=exclude_batch)
|
||||
if exclude_lineage is not None:
|
||||
elsewhere_qs = elsewhere_qs.exclude(pk=exclude_lineage.pk)
|
||||
elsewhere = elsewhere_qs.aggregate(total=Sum("weight_allocated_kg"))["total"] or Decimal("0")
|
||||
if elsewhere + weight > received:
|
||||
others = ", ".join(sorted({row.batch.batch_reference for row in elsewhere_qs}))
|
||||
return (
|
||||
f"{intake.intake_reference} received {received} kg and "
|
||||
f"{elsewhere} kg of it is already allocated to {others}. "
|
||||
f"Only {received - elsewhere} kg is left."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _lineage(batch):
|
||||
return list(batch.intake_lineage.select_related("harvest_intake", "harvest_intake__origin_zone", "harvest_intake__collector_group").all())
|
||||
|
||||
|
|
|
|||
85
apps/compliance/static/js/ixg_zone_map.js
Normal file
85
apps/compliance/static/js/ixg_zone_map.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/* Draw the stored origin-zone polygon on a Leaflet map.
|
||||
*
|
||||
* Display only: this renders what is already stored on the record. It does not
|
||||
* validate the geometry, and it does not approve the zone. Validation and the
|
||||
* two-step verification are separate pieces of work.
|
||||
*
|
||||
* The geometry is passed in a data-* attribute so the browser decodes it (no
|
||||
* JSON injected into a script block).
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var OSM_ATTRIBUTION =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
|
||||
|
||||
function init() {
|
||||
var el = document.getElementById("zone-map");
|
||||
if (!el || typeof L === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
var geometry;
|
||||
try {
|
||||
geometry = JSON.parse(el.dataset.geometry || "null");
|
||||
} catch (err) {
|
||||
el.innerHTML =
|
||||
'<p style="padding:1rem;color:#b91c1c">Stored geometry is not valid JSON, so it cannot be drawn.</p>';
|
||||
return;
|
||||
}
|
||||
if (!geometry || !geometry.coordinates) {
|
||||
return;
|
||||
}
|
||||
|
||||
var map = L.map(el, { scrollWheelZoom: false });
|
||||
|
||||
if (el.dataset.tileUrl) {
|
||||
var tiles = L.tileLayer(el.dataset.tileUrl, {
|
||||
maxZoom: 19,
|
||||
attribution: OSM_ATTRIBUTION,
|
||||
}).addTo(map);
|
||||
|
||||
// If the tile source cannot be reached, the polygon still draws but the
|
||||
// background stays blank. Say so, rather than leaving the viewer to wonder
|
||||
// whether the record itself is broken.
|
||||
var note = null;
|
||||
tiles.on("tileerror", function () {
|
||||
if (note) {
|
||||
return;
|
||||
}
|
||||
note = document.createElement("p");
|
||||
note.style.cssText =
|
||||
"max-width:900px;margin-top:.5rem;font-size:.8rem;color:#b45309";
|
||||
note.textContent =
|
||||
"Map imagery could not be loaded. The area below is still drawn from the " +
|
||||
"stored coordinates, so the shape can still be checked.";
|
||||
el.insertAdjacentElement("afterend", note);
|
||||
});
|
||||
}
|
||||
|
||||
var layer = L.geoJSON(geometry, {
|
||||
style: { color: "#0f766e", weight: 2, fillColor: "#14b8a6", fillOpacity: 0.25 },
|
||||
}).addTo(map);
|
||||
|
||||
var bounds = layer.getBounds();
|
||||
if (bounds && bounds.isValid()) {
|
||||
map.fitBounds(bounds.pad(0.25));
|
||||
} else {
|
||||
map.setView([0, 0], 2);
|
||||
}
|
||||
|
||||
// Grappelli lays the form out after load; a stale size leaves grey tiles.
|
||||
setTimeout(function () {
|
||||
map.invalidateSize();
|
||||
if (bounds && bounds.isValid()) {
|
||||
map.fitBounds(bounds.pad(0.25));
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
|
|
@ -240,6 +240,65 @@ class EUDRWorkflowTests(TestCase):
|
|||
self.assertEqual(case.statement_references.count(), 1)
|
||||
|
||||
|
||||
class LineageAllocationAPITests(TestCase):
|
||||
"""The API refuses the same over-allocation the ops lineage screen refuses."""
|
||||
|
||||
def setUp(self):
|
||||
self.ops = make_user("lineage.ops@ixg.local", "ops")
|
||||
self.batch = make_batch(self.ops)
|
||||
facility = Facility.objects.create(
|
||||
name="Lineage Facility", country="Nigeria", region="Katsina"
|
||||
)
|
||||
zone = OriginZone.objects.create(
|
||||
facility=facility, zone_code="LIN-01", country="Nigeria",
|
||||
region="Katsina", verification_status=OriginZone.VerificationStatus.VERIFIED,
|
||||
)
|
||||
group = CollectorGroup.objects.create(name="Lineage Group", origin_zone=zone)
|
||||
self.intake = HarvestIntake.objects.create(
|
||||
intake_reference="INT-LIN-001", collector_group=group,
|
||||
origin_zone=zone, intake_date="2026-01-10",
|
||||
weight_kg=Decimal("100.00"),
|
||||
)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.ops)
|
||||
|
||||
def post(self, weight):
|
||||
return self.client.post(
|
||||
"/api/intake-batch-lineage/",
|
||||
{
|
||||
"batch": str(self.batch.id),
|
||||
"harvest_intake": str(self.intake.id),
|
||||
"weight_allocated_kg": weight,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
def test_an_allocation_larger_than_the_intake_is_refused(self):
|
||||
response = self.post("9999.00")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(IntakeBatchLineage.objects.exists())
|
||||
|
||||
def test_an_allocation_that_overdraws_with_another_batch_is_refused(self):
|
||||
other = make_batch(self.ops)
|
||||
IntakeBatchLineage.objects.create(
|
||||
batch=other, harvest_intake=self.intake,
|
||||
weight_allocated_kg=Decimal("80.00"),
|
||||
)
|
||||
response = self.post("30.00")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(IntakeBatchLineage.objects.count(), 1)
|
||||
|
||||
def test_what_is_left_of_the_intake_can_be_allocated(self):
|
||||
other = make_batch(self.ops)
|
||||
IntakeBatchLineage.objects.create(
|
||||
batch=other, harvest_intake=self.intake,
|
||||
weight_allocated_kg=Decimal("80.00"),
|
||||
)
|
||||
response = self.post("20.00")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(IntakeBatchLineage.objects.count(), 2)
|
||||
|
||||
|
||||
class BuyerIsolationTests(TestCase):
|
||||
def setUp(self):
|
||||
self.buyer = make_user("iso.buyer@ixg.local", "buyer")
|
||||
|
|
@ -303,3 +362,63 @@ class PageRenderTests(TestCase):
|
|||
self.assertEqual(resp.status_code, 302)
|
||||
self.coa.refresh_from_db()
|
||||
self.assertEqual(self.coa.status, CertificateOfAnalysis.Status.PUBLISHED)
|
||||
|
||||
|
||||
class OriginZoneMapAdminTests(TestCase):
|
||||
"""The admin map that closes the "no map view" gap, and its empty states.
|
||||
|
||||
The map is display only: these tests pin the fact that it draws what is
|
||||
stored, and that a zone without coordinates explains itself instead of
|
||||
rendering an empty box or raising.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_user(
|
||||
email="zoneadmin@ixg.local",
|
||||
password="pass1234",
|
||||
role="admin",
|
||||
is_staff=True,
|
||||
is_superuser=True,
|
||||
)
|
||||
self.facility = Facility.objects.create(
|
||||
name="IXG Processing Facility - Katsina", country="Nigeria",
|
||||
region="Katsina State", facility_type=Facility.FacilityType.PROCESSING,
|
||||
)
|
||||
self.zone = OriginZone.objects.create(
|
||||
facility=self.facility, zone_code="NG-KT-Z99", country="Nigeria",
|
||||
region="Katsina State",
|
||||
geometry={
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[7.6012, 12.9905], [7.618, 12.9905],
|
||||
[7.618, 13.0042], [7.6012, 13.0042],
|
||||
[7.6012, 12.9905]]],
|
||||
},
|
||||
)
|
||||
|
||||
def test_zone_map_draws_the_stored_polygon(self):
|
||||
self.client.force_login(self.admin)
|
||||
resp = self.client.get(f"/admin/compliance/originzone/{self.zone.id}/change/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
html = resp.content.decode()
|
||||
self.assertIn('id="zone-map"', html)
|
||||
self.assertIn("7.6012", html) # the stored coordinates
|
||||
self.assertIn("vendor/leaflet/leaflet.js", html) # the library is loaded
|
||||
self.assertIn("js/ixg_zone_map.js", html)
|
||||
|
||||
def test_zone_without_coordinates_says_so(self):
|
||||
bare = OriginZone.objects.create(
|
||||
facility=self.facility, zone_code="NG-KT-Z98", country="Nigeria",
|
||||
geometry={},
|
||||
)
|
||||
self.client.force_login(self.admin)
|
||||
resp = self.client.get(f"/admin/compliance/originzone/{bare.id}/change/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
html = resp.content.decode()
|
||||
self.assertIn("No polygon coordinates are stored", html)
|
||||
self.assertNotIn('id="zone-map"', html)
|
||||
|
||||
def test_zone_map_on_the_add_page_is_not_an_error(self):
|
||||
self.client.force_login(self.admin)
|
||||
resp = self.client.get("/admin/compliance/originzone/add/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertNotIn('id="zone-map"', resp.content.decode())
|
||||
|
|
|
|||
0
apps/core/management/__init__.py
Normal file
0
apps/core/management/__init__.py
Normal file
0
apps/core/management/commands/__init__.py
Normal file
0
apps/core/management/commands/__init__.py
Normal file
748
apps/core/management/commands/seed_walkthrough.py
Normal file
748
apps/core/management/commands/seed_walkthrough.py
Normal file
|
|
@ -0,0 +1,748 @@
|
|||
"""Seed one coherent, fully fictional batch chain for IXG ops training.
|
||||
|
||||
Everything created here is synthetic: fictional companies, collector groups,
|
||||
field officers, laboratories and vessels. No real buyer, supplier, collector or
|
||||
producer data is used anywhere.
|
||||
|
||||
The chain runs end to end so that the same batch reference appears on every
|
||||
screen an ops trainer walks through:
|
||||
|
||||
origin zone -> collector group -> harvest intakes -> lineage
|
||||
-> processing batch -> QC result -> four-eyes verification
|
||||
-> certificate of analysis (+ lab report, packing list, B/L, phytosanitary)
|
||||
-> EUDR due-diligence case (evidence, risk assessment, DDS reference)
|
||||
-> shipment -> ESG field report -> buyer subscription
|
||||
|
||||
Three batches are prepared on purpose:
|
||||
|
||||
IXG-BATCH-2026-014 completed happy path (delivered) - the buyer's view
|
||||
IXG-BATCH-2026-005 mid-flight (QC passed, CoA published, in transit)
|
||||
IXG-TEST-001 QC failure + CAPA - the exception branch
|
||||
|
||||
Usage (inside the app container):
|
||||
|
||||
python manage.py seed_walkthrough
|
||||
python manage.py seed_walkthrough --reset # remove seeded rows first
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.units import mm
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
from apps.capa.models import CAPARecord
|
||||
from apps.compliance import services
|
||||
from apps.compliance.models import (
|
||||
CollectorGroup,
|
||||
DueDiligenceCase,
|
||||
DueDiligenceStatementReference,
|
||||
EUDREvidence,
|
||||
Facility,
|
||||
HarvestIntake,
|
||||
IntakeBatchLineage,
|
||||
MitigationAction,
|
||||
OriginZone,
|
||||
ProductSpecification,
|
||||
RiskAssessment,
|
||||
)
|
||||
from apps.documents.models import CertificateOfAnalysis, Document, RegulatoryCert
|
||||
from apps.esg.models import ESGFieldReport
|
||||
from apps.qc.models import QCResult
|
||||
from apps.shipments.models import Shipment
|
||||
from apps.subscriptions.models import Subscription
|
||||
from apps.users.auth import is_staff_for_role
|
||||
from apps.users.models import User
|
||||
|
||||
BUYER_EMAIL = "buyer@greenfieldfoods.example"
|
||||
BUYER_NAME = "Greenfield Foods Ltd (dummy buyer)"
|
||||
LAB_NAME = "Northgate Analytical Services Ltd (dummy laboratory)"
|
||||
FORWARDER = "Meridian Freight Forwarding Ltd (dummy forwarder)"
|
||||
|
||||
COMPLETED_BATCH = "IXG-BATCH-2026-014"
|
||||
INFLIGHT_BATCH = "IXG-BATCH-2026-005"
|
||||
FAILED_BATCH = "IXG-TEST-001"
|
||||
|
||||
DOCS_ROOT = Path(settings.MEDIA_ROOT) / "ixg-documents"
|
||||
|
||||
|
||||
def _pdf(rel_dir: str, filename: str, heading: str, subheading: str,
|
||||
lines: list[str], table: list[list[str]] | None = None) -> dict:
|
||||
"""Render a small, realistic-enough document and return its file metadata."""
|
||||
folder = DOCS_ROOT / rel_dir
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
path = folder / filename
|
||||
|
||||
c = canvas.Canvas(str(path), pagesize=A4)
|
||||
width, height = A4
|
||||
c.setFillColor(colors.HexColor("#1A3D2B"))
|
||||
c.rect(0, height - 28 * mm, width, 28 * mm, stroke=0, fill=1)
|
||||
c.setFillColor(colors.white)
|
||||
c.setFont("Helvetica-Bold", 16)
|
||||
c.drawString(18 * mm, height - 15 * mm, "IXG LTD")
|
||||
c.setFont("Helvetica", 9)
|
||||
c.drawString(18 * mm, height - 21 * mm, "Unrefined shea butter - Nigeria to United Kingdom")
|
||||
c.setFillColor(colors.HexColor("#1A1A1A"))
|
||||
c.setFont("Helvetica-Bold", 13)
|
||||
c.drawString(18 * mm, height - 40 * mm, heading)
|
||||
c.setFont("Helvetica-Oblique", 9.5)
|
||||
c.drawString(18 * mm, height - 46 * mm, subheading)
|
||||
|
||||
y = height - 58 * mm
|
||||
c.setFont("Helvetica", 10)
|
||||
for line in lines:
|
||||
c.drawString(18 * mm, y, line)
|
||||
y -= 5.5 * mm
|
||||
|
||||
if table:
|
||||
y -= 3 * mm
|
||||
c.setFont("Helvetica-Bold", 9.5)
|
||||
c.drawString(18 * mm, y, table[0][0])
|
||||
c.drawString(90 * mm, y, table[0][1])
|
||||
c.drawString(140 * mm, y, table[0][2])
|
||||
y -= 2 * mm
|
||||
c.setStrokeColor(colors.HexColor("#CCD3CF"))
|
||||
c.line(18 * mm, y, 175 * mm, y)
|
||||
c.setFont("Helvetica", 9.5)
|
||||
for row in table[1:]:
|
||||
y -= 5.5 * mm
|
||||
c.drawString(18 * mm, y, row[0])
|
||||
c.drawString(90 * mm, y, row[1])
|
||||
c.drawString(140 * mm, y, row[2])
|
||||
|
||||
c.setFont("Helvetica-Oblique", 8)
|
||||
c.setFillColor(colors.HexColor("#5B625F"))
|
||||
c.drawString(18 * mm, 22 * mm, "SYNTHETIC DOCUMENT - generated for IXG platform training. Not a real certificate.")
|
||||
c.showPage()
|
||||
c.save()
|
||||
|
||||
data = path.read_bytes()
|
||||
return {
|
||||
"file_url": f"{settings.MEDIA_URL}ixg-documents/{rel_dir}/{filename}",
|
||||
"file_name": filename,
|
||||
"sha256": hashlib.sha256(data).hexdigest(),
|
||||
"size": len(data),
|
||||
}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed the fictional end-to-end batch chain used for ops training walkthroughs."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--reset",
|
||||
action="store_true",
|
||||
help="Delete the seeded records for the three demo batches before re-creating them.",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def _user(self, email: str) -> User | None:
|
||||
return User.objects.filter(email__iexact=email).first()
|
||||
|
||||
def _ops_users(self) -> tuple[User | None, User | None]:
|
||||
"""Two different ops accounts, so the four-eyes step is genuinely two pairs of eyes."""
|
||||
enterer = self._user("test@ixg.local") or User.objects.filter(role="ops").order_by("email").first()
|
||||
verifier = (
|
||||
User.objects.filter(role="ops")
|
||||
.exclude(pk=getattr(enterer, "pk", None))
|
||||
.order_by("email")
|
||||
.first()
|
||||
) or enterer
|
||||
return enterer, verifier
|
||||
|
||||
def _spec(self) -> ProductSpecification | None:
|
||||
return (
|
||||
ProductSpecification.objects.filter(product__icontains="shea", is_current=True).first()
|
||||
or ProductSpecification.current("Unrefined Shea Butter")
|
||||
)
|
||||
|
||||
def _reset(self) -> None:
|
||||
for ref in (COMPLETED_BATCH, INFLIGHT_BATCH, FAILED_BATCH):
|
||||
ProcessingBatch.objects.filter(batch_reference=ref).delete()
|
||||
self.stdout.write(" reset: removed seeded batches")
|
||||
|
||||
# --------------------------------------------------------------------- steps
|
||||
|
||||
def _origin_chain(self, verifier: User) -> tuple[OriginZone, list[HarvestIntake], list[HarvestIntake]]:
|
||||
"""The zone, the groups, and two separate sets of harvest intakes.
|
||||
|
||||
Two sets, not one, because an intake is a finite quantity of shea: the
|
||||
completed batch and the in-flight batch each consume their own material.
|
||||
The command used to hand the same three intakes to both batches, which
|
||||
allocated 800 kg of an 820 kg intake twice over — invisible until the ops
|
||||
portal grew a lineage screen that refuses exactly that.
|
||||
"""
|
||||
facility, _ = Facility.objects.update_or_create(
|
||||
name="IXG Processing Facility - Katsina",
|
||||
defaults={
|
||||
"country": "Nigeria",
|
||||
"region": "Katsina State",
|
||||
"address": "Industrial Layout, Katsina State, Nigeria (dummy address)",
|
||||
"facility_type": Facility.FacilityType.PROCESSING,
|
||||
"is_active": True,
|
||||
},
|
||||
)
|
||||
|
||||
zone, _ = OriginZone.objects.update_or_create(
|
||||
zone_code="NG-KT-Z14",
|
||||
defaults={
|
||||
"facility": facility,
|
||||
"country": "Nigeria",
|
||||
"region": "Katsina State",
|
||||
"geometry": {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[7.6012, 12.9905], [7.6180, 12.9905], [7.6180, 13.0042], [7.6012, 13.0042], [7.6012, 12.9905]]],
|
||||
},
|
||||
"coordinate_source": "IFO handheld GPS capture (synthetic coordinates)",
|
||||
"coordinates_collected_by": "IFO - A. Bello (dummy officer)",
|
||||
"coordinates_collected_at": date(2026, 8, 18),
|
||||
"evidence_file": "origin-zone-NG-KT-Z14-geolocation.pdf",
|
||||
"verification_status": OriginZone.VerificationStatus.VERIFIED,
|
||||
"verified_by": verifier,
|
||||
"verified_at": timezone.now() - timedelta(days=20),
|
||||
},
|
||||
)
|
||||
|
||||
group_a, _ = CollectorGroup.objects.update_or_create(
|
||||
name="Kaduna Road Women's Shea Collective (dummy)",
|
||||
defaults={
|
||||
"contact_reference": "CG-014",
|
||||
"origin_zone": zone,
|
||||
"notes": "Synthetic collector group used for training. 42 registered pickers.",
|
||||
},
|
||||
)
|
||||
group_b, _ = CollectorGroup.objects.update_or_create(
|
||||
name="Dutsin-Ma Shea Pickers Association (dummy)",
|
||||
defaults={
|
||||
"contact_reference": "CG-015",
|
||||
"origin_zone": zone,
|
||||
"notes": "Synthetic collector group used for training. 28 registered pickers.",
|
||||
},
|
||||
)
|
||||
|
||||
intakes = []
|
||||
intake_specs = [
|
||||
("IXG-INTAKE-2026-0141", group_a, date(2026, 8, 25), Decimal("820.00"), "A"),
|
||||
("IXG-INTAKE-2026-0142", group_a, date(2026, 8, 27), Decimal("640.00"), "A"),
|
||||
("IXG-INTAKE-2026-0143", group_b, date(2026, 8, 29), Decimal("600.00"), "B"),
|
||||
]
|
||||
# The in-flight batch's own material, delivered the following week.
|
||||
inflight_specs = [
|
||||
("IXG-INTAKE-2026-0144", group_a, date(2026, 8, 30), Decimal("560.00"), "A"),
|
||||
("IXG-INTAKE-2026-0145", group_b, date(2026, 9, 1), Decimal("540.00"), "A"),
|
||||
("IXG-INTAKE-2026-0146", group_b, date(2026, 9, 3), Decimal("460.00"), "B"),
|
||||
]
|
||||
for ref, group, when, weight, grade in intake_specs + inflight_specs:
|
||||
intake, _ = HarvestIntake.objects.update_or_create(
|
||||
intake_reference=ref,
|
||||
defaults={
|
||||
"collector_group": group,
|
||||
"origin_zone": zone,
|
||||
"intake_date": when,
|
||||
"weight_kg": weight,
|
||||
"grade": grade,
|
||||
"receiving_officer": "IFO - A. Bello (dummy officer)",
|
||||
"status": HarvestIntake.Status.VERIFIED,
|
||||
"evidence_file": f"{ref}-weighbridge-ticket.pdf",
|
||||
},
|
||||
)
|
||||
intakes.append(intake)
|
||||
return zone, intakes[: len(intake_specs)], intakes[len(intake_specs):]
|
||||
|
||||
def _batch(self, ref: str, intakes: list[HarvestIntake], enterer: User, **fields) -> ProcessingBatch:
|
||||
allocations = fields.pop("allocations", [Decimal("800.00"), Decimal("640.00"), Decimal("560.00")])
|
||||
batch, created = ProcessingBatch.objects.update_or_create(
|
||||
batch_reference=ref,
|
||||
defaults={"origin_facility": "ASI - Katsina State, Nigeria", "created_by": enterer, **fields},
|
||||
)
|
||||
for intake, weight in zip(intakes, allocations):
|
||||
IntakeBatchLineage.objects.update_or_create(
|
||||
batch=batch,
|
||||
harvest_intake=intake,
|
||||
defaults={"weight_allocated_kg": weight},
|
||||
)
|
||||
# ...and drop anything this batch used to be linked to. Without this the
|
||||
# command is only idempotent while the chain does not change: re-running it
|
||||
# after a change leaves the old links in place, which is how both demo
|
||||
# batches came to be traced back to the same three intakes.
|
||||
IntakeBatchLineage.objects.filter(batch=batch).exclude(
|
||||
harvest_intake__in=[i.pk for i in intakes]
|
||||
).delete()
|
||||
return batch
|
||||
|
||||
def _qc(self, batch: ProcessingBatch, enterer: User, verifier: User, spec: ProductSpecification | None) -> QCResult:
|
||||
qc = batch.qc_results.order_by("-tested_at").first()
|
||||
if qc is None:
|
||||
qc = QCResult(batch=batch)
|
||||
qc.ffa_percentage = Decimal("0.42")
|
||||
qc.moisture_percentage = Decimal("0.08")
|
||||
qc.peroxide_value = Decimal("1.20")
|
||||
qc.visual_grade = QCResult.VisualGrade.A
|
||||
qc.overall_pass = True
|
||||
qc.tested_by = LAB_NAME
|
||||
qc.lab_reference = "NAS-2026-0914"
|
||||
qc.spec_version = spec.version if spec else "1.0"
|
||||
qc.entered_by = enterer
|
||||
qc.verified_by = verifier
|
||||
qc.verification_status = QCResult.VerificationStatus.APPROVED
|
||||
qc.approved_at = timezone.now() - timedelta(days=10)
|
||||
qc.method_ffa = "ISO 660"
|
||||
qc.method_moisture = "ISO 662"
|
||||
qc.method_peroxide = "ISO 3960"
|
||||
qc.result_status = QCResult.ResultStatus.WITHIN_SPEC
|
||||
qc.is_quarantine = False
|
||||
qc.save()
|
||||
return qc
|
||||
|
||||
def _failed_qc(self, batch: ProcessingBatch, enterer: User, spec):
|
||||
"""The exception-branch result: a real failure, judged by the real evaluator.
|
||||
|
||||
The values fail on **moisture, 0.31 % against the 0.100 % limit** — the
|
||||
story the CAPA and the walkthrough tell. ``overall_pass`` and
|
||||
``result_status`` are computed by ``services.evaluate_qc_data`` rather than
|
||||
asserted here, so a stored status can never contradict the stored values
|
||||
(this record used to carry ``within_spec`` beside ``overall_pass = False``).
|
||||
|
||||
Left awaiting verification on purpose: that is what puts it in the QC
|
||||
Approvals queue, so the four-eyes control can be walked in training.
|
||||
"""
|
||||
qc = batch.qc_results.order_by("-tested_at").first() or QCResult(batch=batch)
|
||||
qc.ffa_percentage = Decimal("0.42")
|
||||
qc.moisture_percentage = Decimal("0.31")
|
||||
qc.peroxide_value = Decimal("1.20")
|
||||
qc.visual_grade = QCResult.VisualGrade.A
|
||||
qc.tested_by = LAB_NAME
|
||||
qc.lab_reference = "NAS-2026-0921"
|
||||
qc.entered_by = enterer
|
||||
qc.verified_by = None
|
||||
qc.approved_at = None
|
||||
qc.verification_status = QCResult.VerificationStatus.DRAFT
|
||||
qc.method_ffa = "ISO 660"
|
||||
qc.method_moisture = "ISO 662"
|
||||
qc.method_peroxide = "ISO 3960"
|
||||
|
||||
values = {
|
||||
"ffa": str(qc.ffa_percentage),
|
||||
"moisture": str(qc.moisture_percentage),
|
||||
"peroxide": str(qc.peroxide_value),
|
||||
"visual": qc.visual_grade,
|
||||
}
|
||||
overall_pass, result_status, details, spec_version = services.evaluate_qc_data(
|
||||
values, spec=spec
|
||||
)
|
||||
qc.overall_pass = overall_pass
|
||||
qc.result_status = result_status
|
||||
qc.spec_version = spec_version
|
||||
# Mirrors QCResultCreateSerializer: a failing result quarantines the batch.
|
||||
qc.is_quarantine = not overall_pass
|
||||
qc.save()
|
||||
return qc, details
|
||||
|
||||
def _documents(self, batch: ProcessingBatch, qc: QCResult, enterer: User,
|
||||
verifier: User, coa_issue: date, is_completed: bool,
|
||||
coa_published: bool = True) -> CertificateOfAnalysis:
|
||||
"""Attach the lab report, and the CoA either released or awaiting release.
|
||||
|
||||
Mirrors the real flow: the CoA record exists first (pending approval) and
|
||||
only becomes a buyer-visible document when it is released.
|
||||
"""
|
||||
ref = batch.batch_reference
|
||||
folder = ref.lower()
|
||||
|
||||
lab = _pdf(
|
||||
folder, f"lab-report-{ref}.pdf",
|
||||
"Laboratory Report", f"Sample reference {qc.lab_reference}",
|
||||
[
|
||||
f"Batch: {ref}",
|
||||
"Product: Unrefined Shea Butter",
|
||||
f"Laboratory: {LAB_NAME}",
|
||||
f"Sample received: {coa_issue.strftime('%d %B %Y')}",
|
||||
f"Specification: Unrefined Shea Butter v{qc.spec_version}",
|
||||
],
|
||||
table=[
|
||||
["Parameter", "Result", "Specification limit"],
|
||||
["Free Fatty Acid (%)", "0.42", "max 1.00"],
|
||||
["Moisture (%)", "0.08", "max 0.20"],
|
||||
["Peroxide value (meq/kg)", "1.20", "max 5.00"],
|
||||
["Visual grade", "A", "A or B"],
|
||||
],
|
||||
)
|
||||
Document.objects.update_or_create(
|
||||
batch=batch, doc_type=Document.DocType.LAB_REPORT, file_name=lab["file_name"],
|
||||
defaults={
|
||||
"file_url": lab["file_url"], "file_size_bytes": lab["size"],
|
||||
"visibility": Document.Visibility.OPS, "uploaded_by": enterer,
|
||||
},
|
||||
)
|
||||
|
||||
coa_pdf = _pdf(
|
||||
folder, f"certificate-of-analysis-{ref}.pdf",
|
||||
"Certificate of Analysis", f"Certificate {qc.lab_reference} - issued {coa_issue.strftime('%d %B %Y')}",
|
||||
[
|
||||
f"Batch: {ref}",
|
||||
"Product: Unrefined Shea Butter (Grade A)",
|
||||
"Origin: Katsina State, Nigeria",
|
||||
f"Issued by: {LAB_NAME}",
|
||||
f"Specification: Unrefined Shea Butter v{qc.spec_version}",
|
||||
],
|
||||
table=[
|
||||
["Parameter", "Result", "Specification limit"],
|
||||
["Free Fatty Acid (%)", "0.42", "max 1.00"],
|
||||
["Moisture (%)", "0.08", "max 0.20"],
|
||||
["Peroxide value (meq/kg)", "1.20", "max 5.00"],
|
||||
["Visual grade", "A", "A or B"],
|
||||
],
|
||||
)
|
||||
source_doc = None
|
||||
if coa_published:
|
||||
source_doc = Document.objects.update_or_create(
|
||||
batch=batch, doc_type=Document.DocType.COA, file_name=coa_pdf["file_name"],
|
||||
defaults={
|
||||
"file_url": coa_pdf["file_url"], "file_size_bytes": coa_pdf["size"],
|
||||
"visibility": Document.Visibility.BUYER, "uploaded_by": enterer,
|
||||
},
|
||||
)[0]
|
||||
else:
|
||||
# not released yet, so nothing buyer-visible exists
|
||||
Document.objects.filter(
|
||||
batch=batch, doc_type=Document.DocType.COA, file_name=coa_pdf["file_name"]
|
||||
).delete()
|
||||
|
||||
coa, _ = CertificateOfAnalysis.objects.update_or_create(
|
||||
batch=batch, version=1,
|
||||
defaults={
|
||||
"qc_result": qc,
|
||||
"file_url": coa_pdf["file_url"],
|
||||
"file_name": coa_pdf["file_name"],
|
||||
"issuer": LAB_NAME,
|
||||
"issue_date": coa_issue,
|
||||
"expiry_date": coa_issue + timedelta(days=365),
|
||||
"uploaded_by": enterer,
|
||||
"status": (
|
||||
CertificateOfAnalysis.Status.PUBLISHED if coa_published
|
||||
else CertificateOfAnalysis.Status.PENDING_APPROVAL
|
||||
),
|
||||
"sha256_hash": coa_pdf["sha256"],
|
||||
"spec_version": qc.spec_version,
|
||||
"source_document": source_doc,
|
||||
"quality_approved_by": verifier,
|
||||
"quality_approved_at": timezone.now() - timedelta(days=9),
|
||||
"released_by": enterer if coa_published else None,
|
||||
"released_at": timezone.now() - timedelta(days=8) if coa_published else None,
|
||||
"published_at": timezone.now() - timedelta(days=8) if coa_published else None,
|
||||
},
|
||||
)
|
||||
|
||||
if is_completed:
|
||||
packing = _pdf(
|
||||
folder, f"packing-list-{ref}.pdf", "Packing List",
|
||||
f"Consignment {ref} - 80 x 25 kg food-grade drums",
|
||||
[
|
||||
f"Batch: {ref}",
|
||||
"Product: Unrefined Shea Butter",
|
||||
"Net weight: 2,000 kg",
|
||||
"Packaging: 80 x 25 kg food-grade poly-lined drums",
|
||||
"Container: IXGU2450163",
|
||||
],
|
||||
)
|
||||
bl = _pdf(
|
||||
folder, f"bill-of-lading-{ref}.pdf", "Bill of Lading",
|
||||
"Original - port to port",
|
||||
[
|
||||
f"Goods: Unrefined Shea Butter, batch {ref}",
|
||||
"Port of loading: Apapa Port, Lagos, Nigeria",
|
||||
"Port of discharge: Port of Felixstowe, United Kingdom",
|
||||
"Vessel: MV Atlantic Shea Star (dummy vessel)",
|
||||
"Container: IXGU2450163",
|
||||
],
|
||||
)
|
||||
phyto = _pdf(
|
||||
folder, f"phytosanitary-certificate-{ref}.pdf", "Phytosanitary Certificate",
|
||||
"Nigeria Agricultural Quarantine Service (dummy)",
|
||||
[
|
||||
f"Consignment: {ref}",
|
||||
"Product: Unrefined Shea Butter (processed, non-viable)",
|
||||
"Declared for export to the United Kingdom",
|
||||
],
|
||||
)
|
||||
for meta, doc_type in ((packing, Document.DocType.PACKING_LIST), (bl, Document.DocType.BL)):
|
||||
Document.objects.update_or_create(
|
||||
batch=batch, doc_type=doc_type, file_name=meta["file_name"],
|
||||
defaults={
|
||||
"file_url": meta["file_url"], "file_size_bytes": meta["size"],
|
||||
"visibility": Document.Visibility.BUYER, "uploaded_by": enterer,
|
||||
},
|
||||
)
|
||||
Document.objects.update_or_create(
|
||||
batch=batch, doc_type=Document.DocType.REGULATORY, file_name=phyto["file_name"],
|
||||
defaults={
|
||||
"file_url": phyto["file_url"], "file_size_bytes": phyto["size"],
|
||||
"visibility": Document.Visibility.BUYER, "uploaded_by": enterer,
|
||||
},
|
||||
)
|
||||
RegulatoryCert.objects.update_or_create(
|
||||
batch=batch, cert_type=RegulatoryCert.CertType.PHYTOSANITARY,
|
||||
defaults={
|
||||
"file_url": phyto["file_url"], "file_name": phyto["file_name"],
|
||||
"cert_reference": f"NAQS-{ref}", "issue_date": coa_issue + timedelta(days=10),
|
||||
"expiry_date": coa_issue + timedelta(days=100),
|
||||
},
|
||||
)
|
||||
|
||||
return coa
|
||||
|
||||
def _eudr(self, batch: ProcessingBatch, zone: OriginZone, intakes: list[HarvestIntake],
|
||||
enterer: User, verifier: User, approved: bool) -> DueDiligenceCase:
|
||||
case, _ = DueDiligenceCase.objects.update_or_create(
|
||||
batch=batch,
|
||||
defaults={
|
||||
"status": DueDiligenceCase.Status.APPROVED if approved
|
||||
else DueDiligenceCase.Status.COMPLETE_FOR_REVIEW,
|
||||
"risk_level": DueDiligenceCase.RiskLevel.LOW,
|
||||
"assessed_by": verifier,
|
||||
"assessed_at": timezone.now() - timedelta(days=7),
|
||||
"approved_by": verifier if approved else None,
|
||||
"approved_at": timezone.now() - timedelta(days=6) if approved else None,
|
||||
},
|
||||
)
|
||||
|
||||
evidence_files = [
|
||||
f"{zone.zone_code}-geolocation.json",
|
||||
f"{intakes[0].intake_reference}-weighbridge-ticket.pdf",
|
||||
"collector-group-register.pdf",
|
||||
"export-permit-and-land-use.pdf",
|
||||
]
|
||||
for etype, name in (
|
||||
(EUDREvidence.EvidenceType.GEOLOCATION, evidence_files[0]),
|
||||
(EUDREvidence.EvidenceType.HARVEST_DOCUMENT, evidence_files[1]),
|
||||
(EUDREvidence.EvidenceType.SUPPLIER_IDENTITY, evidence_files[2]),
|
||||
(EUDREvidence.EvidenceType.LEGALITY, evidence_files[3]),
|
||||
):
|
||||
EUDREvidence.objects.update_or_create(
|
||||
case=case, evidence_type=etype, file=name,
|
||||
defaults={
|
||||
"source": "IFO field capture (dummy)",
|
||||
"collected_at": date(2026, 8, 20),
|
||||
"verified_by": verifier,
|
||||
"verified_at": timezone.now() - timedelta(days=7),
|
||||
"notes": "Synthetic evidence record for training.",
|
||||
},
|
||||
)
|
||||
# ...and drop anything this case used to carry. Evidence is keyed on the
|
||||
# filename, so reassigning the batch's intakes leaves the old
|
||||
# harvest_document row behind. Mirrors the lineage cleanup in _batch.
|
||||
EUDREvidence.objects.filter(case=case).exclude(
|
||||
file__in=evidence_files
|
||||
).delete()
|
||||
|
||||
RiskAssessment.objects.update_or_create(
|
||||
case=case, risk_level=DueDiligenceCase.RiskLevel.LOW,
|
||||
defaults={
|
||||
"basis": "Country risk benchmarking plus origin-zone geolocation screening (synthetic).",
|
||||
"findings": "No deforestation, no protected-area overlap, no land-tenure dispute identified.",
|
||||
"assessed_by": verifier,
|
||||
},
|
||||
)
|
||||
MitigationAction.objects.update_or_create(
|
||||
case=case, action="Annual re-verification of collector group membership register",
|
||||
defaults={
|
||||
"status": MitigationAction.Status.COMPLETED,
|
||||
"owner": "IXG Compliance (dummy owner)",
|
||||
"due_date": date(2027, 8, 31),
|
||||
"evidence_file": "collector-group-register-reverification.pdf",
|
||||
"completed_at": timezone.now() - timedelta(days=5),
|
||||
},
|
||||
)
|
||||
DueDiligenceStatementReference.objects.update_or_create(
|
||||
case=case, dds_reference=f"DDS-2026-{batch.batch_reference[-3:]}",
|
||||
defaults={
|
||||
"submission_date": date(2026, 9, 2),
|
||||
"submitted_by": enterer,
|
||||
"reconciliation_status": DueDiligenceStatementReference.ReconciliationStatus.RECONCILED,
|
||||
"notes": "Synthetic DDS reference recorded for training.",
|
||||
},
|
||||
)
|
||||
|
||||
# Run the REAL completeness check rather than inventing results, so the
|
||||
# seeded case matches what the ops screen shows when the check is re-run.
|
||||
from apps.compliance.services import run_completeness_check
|
||||
|
||||
run_completeness_check(case)
|
||||
case.refresh_from_db()
|
||||
return case
|
||||
|
||||
def _shipment(self, batch: ProcessingBatch, delivered: bool) -> Shipment:
|
||||
defaults = {
|
||||
"status": Shipment.Status.DELIVERED if delivered else Shipment.Status.IN_TRANSIT,
|
||||
"origin_port": "Apapa Port, Lagos",
|
||||
"destination_port": "Port of Felixstowe",
|
||||
"vessel_name": "MV Atlantic Shea Star (dummy vessel)",
|
||||
"container_ref": "IXGU2450163",
|
||||
"etd_nigeria": date(2026, 9, 8) if delivered else date(2026, 9, 14),
|
||||
"eta_uk": date(2026, 9, 28) if delivered else date(2026, 10, 5),
|
||||
"bl_number": f"MAEU-260824-{batch.batch_reference[-3:]}",
|
||||
"freight_forwarder": FORWARDER,
|
||||
}
|
||||
shipment = batch.shipments.first()
|
||||
if shipment is None:
|
||||
return Shipment.objects.create(batch=batch, **defaults)
|
||||
for key, value in defaults.items():
|
||||
setattr(shipment, key, value)
|
||||
shipment.save()
|
||||
return shipment
|
||||
|
||||
def _esg(self, batch: ProcessingBatch, reports: list[tuple[date, int, int, str]]) -> None:
|
||||
for report_date, total, women, note in reports:
|
||||
ESGFieldReport.objects.update_or_create(
|
||||
batch=batch, report_date=report_date,
|
||||
defaults={
|
||||
"women_employed": women,
|
||||
"total_workers": total,
|
||||
"fair_wage_paid": True,
|
||||
"safety_equipment_used": True,
|
||||
"community_notes": note,
|
||||
"reported_by": "IFO - A. Bello (dummy officer)",
|
||||
},
|
||||
)
|
||||
|
||||
def _buyer(self) -> User:
|
||||
buyer, _ = User.objects.get_or_create(
|
||||
email=BUYER_EMAIL,
|
||||
defaults={"full_name": BUYER_NAME, "role": "buyer", "is_active": True, "is_staff": False},
|
||||
)
|
||||
buyer.role = "buyer"
|
||||
buyer.full_name = BUYER_NAME
|
||||
# Staff status is derived from the role, and the seed must not leave the
|
||||
# flag disagreeing with it: a staff buyer is inside the admin area until
|
||||
# the next sign-in re-derives the flag. Setting it on the update path too
|
||||
# closes the window the seed used to reopen on every run.
|
||||
buyer.is_staff = is_staff_for_role(buyer.role)
|
||||
buyer.is_active = True
|
||||
buyer.save(update_fields=["role", "full_name", "is_staff", "is_active"])
|
||||
Subscription.objects.update_or_create(
|
||||
user=buyer, plan=Subscription.Plan.PROFESSIONAL,
|
||||
defaults={
|
||||
"status": Subscription.Status.ACTIVE,
|
||||
"stripe_price_id": "price_dummy_professional",
|
||||
"current_period_end": timezone.now() + timedelta(days=305),
|
||||
},
|
||||
)
|
||||
return buyer
|
||||
|
||||
# ---------------------------------------------------------------------- main
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options["reset"]:
|
||||
self._reset()
|
||||
|
||||
enterer, verifier = self._ops_users()
|
||||
spec = self._spec()
|
||||
self.stdout.write(f" ops enterer: {getattr(enterer, 'email', 'none')}")
|
||||
self.stdout.write(f" ops verifier: {getattr(verifier, 'email', 'none')} (four-eyes)")
|
||||
self.stdout.write(f" spec: {spec}")
|
||||
|
||||
if enterer is None:
|
||||
self.stderr.write("No ops user found - create one before seeding.")
|
||||
return
|
||||
|
||||
zone, intakes, inflight_intakes = self._origin_chain(verifier or enterer)
|
||||
self.stdout.write(
|
||||
f" origin: {zone.zone_code}, "
|
||||
f"{len(intakes) + len(inflight_intakes)} harvest intakes "
|
||||
f"({len(intakes)} for {COMPLETED_BATCH}, {len(inflight_intakes)} for {INFLIGHT_BATCH})"
|
||||
)
|
||||
|
||||
# 1. completed happy path
|
||||
completed = self._batch(
|
||||
COMPLETED_BATCH, intakes, enterer,
|
||||
harvest_date=date(2026, 8, 30), weight_kg=Decimal("2000.00"),
|
||||
grade=ProcessingBatch.Grade.A, status=ProcessingBatch.Status.DELIVERED,
|
||||
notes="Synthetic batch used for IXG ops training walkthroughs.",
|
||||
)
|
||||
qc = self._qc(completed, enterer, verifier or enterer, spec)
|
||||
coa = self._documents(completed, qc, enterer, verifier or enterer, date(2026, 9, 1), is_completed=True)
|
||||
self._eudr(completed, zone, intakes, enterer, verifier or enterer, approved=True)
|
||||
self._shipment(completed, delivered=True)
|
||||
self._esg(completed, [
|
||||
(date(2026, 8, 26), 34, 21, "Synthetic report: shea processing training held with the collective."),
|
||||
(date(2026, 9, 2), 31, 19, "Synthetic report: protective gloves and aprons issued to all pickers."),
|
||||
])
|
||||
self.stdout.write(f" {COMPLETED_BATCH}: delivered, QC pass, CoA published ({coa.sha256_hash[:12]}...), EUDR approved")
|
||||
|
||||
# 2. mid-flight. Its own intakes: three intakes cannot be allocated into
|
||||
# two batches, and the lineage screen would refuse to save that.
|
||||
inflight = self._batch(
|
||||
INFLIGHT_BATCH, inflight_intakes, enterer,
|
||||
allocations=[Decimal("540.00"), Decimal("520.00"), Decimal("440.00")],
|
||||
harvest_date=date(2026, 9, 4), weight_kg=Decimal("1500.00"),
|
||||
grade=ProcessingBatch.Grade.B, status=ProcessingBatch.Status.IN_TRANSIT,
|
||||
notes="Synthetic batch currently in transit - used to show work in progress.",
|
||||
)
|
||||
qc2 = self._qc(inflight, enterer, verifier or enterer, spec)
|
||||
self._documents(inflight, qc2, enterer, verifier or enterer, date(2026, 9, 10),
|
||||
is_completed=False, coa_published=False)
|
||||
self._eudr(inflight, zone, inflight_intakes, enterer, verifier or enterer, approved=False)
|
||||
self._shipment(inflight, delivered=False)
|
||||
self._esg(inflight, [(date(2026, 9, 6), 27, 16, "Synthetic report: intake day observations.")])
|
||||
self.stdout.write(f" {INFLIGHT_BATCH}: in transit, CoA awaiting release, EUDR complete for review")
|
||||
|
||||
# 3. exception branch. The batch is created here as well, so this branch is
|
||||
# reproducible on an empty database rather than depending on a record
|
||||
# that happened to already exist. No intake lineage on purpose: its risk
|
||||
# assessment is the "untraceable origin" example.
|
||||
failed = self._batch(
|
||||
FAILED_BATCH, [], enterer,
|
||||
harvest_date=date(2026, 7, 15), weight_kg=Decimal("900.00"),
|
||||
grade=ProcessingBatch.Grade.A, status=ProcessingBatch.Status.QC_FAIL,
|
||||
notes="Synthetic batch used to demonstrate the exception path (QC failure).",
|
||||
)
|
||||
qc_failed, details = self._failed_qc(failed, enterer, spec)
|
||||
|
||||
# The batch follows its result, as it does through the API.
|
||||
failed.status = ProcessingBatch.Status.QC_FAIL
|
||||
failed.save(update_fields=["status", "updated_at"])
|
||||
|
||||
# The CAPA is raised with the description the platform itself writes
|
||||
# (QCResultCreateSerializer), then worked — the root cause and the
|
||||
# actions are what an operator fills in, and the status moves on.
|
||||
# exactly one CAPA per demo batch, however often this command is re-run
|
||||
CAPARecord.objects.filter(batch=failed).delete()
|
||||
CAPARecord.objects.create(
|
||||
batch=failed,
|
||||
issue_description=f"QC failure on batch {failed.batch_reference}",
|
||||
root_cause="Drying time shortened during a heavy-rain week at the processing site.",
|
||||
corrective_action="Batch placed on hold and re-dried; re-sampled and re-tested by the laboratory.",
|
||||
preventive_action="Minimum drying time added to the processing checklist; daily moisture spot checks.",
|
||||
status=CAPARecord.Status.IN_PROGRESS,
|
||||
assigned_to="IXG Quality (dummy owner)",
|
||||
due_date=date(2026, 10, 15),
|
||||
)
|
||||
failing = ", ".join(
|
||||
f"{name} {detail['detail']}"
|
||||
for name, detail in details.items()
|
||||
if detail["status"] == "outside"
|
||||
)
|
||||
self.stdout.write(
|
||||
f" {FAILED_BATCH}: QC {qc_failed.get_result_status_display()} "
|
||||
f"({failing or 'no parameter detail'}), quarantined, awaiting "
|
||||
f"verification; CAPA raised and worked"
|
||||
)
|
||||
|
||||
buyer = self._buyer()
|
||||
self.stdout.write(f" buyer: {buyer.email} ({buyer.role}) with an active Professional subscription")
|
||||
|
||||
files = sorted(p.name for p in DOCS_ROOT.glob("*/*.pdf"))
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"seed_walkthrough complete: 2 demo batches + exception CAPA, {len(files)} PDF files in {DOCS_ROOT}"
|
||||
))
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import re
|
||||
from django.shortcuts import redirect
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect
|
||||
|
||||
# The administration area is decided by role, not by the staff flag alone.
|
||||
ADMIN_PATH = re.compile(r"^/admin/")
|
||||
|
||||
|
||||
class RBACMiddleware:
|
||||
|
|
@ -14,11 +18,34 @@ class RBACMiddleware:
|
|||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if request.user.is_authenticated and not request.user.is_staff:
|
||||
user = request.user
|
||||
if user.is_authenticated and not user.is_superuser:
|
||||
path = request.path_info
|
||||
user_role = getattr(request.user, "role", None)
|
||||
for role, patterns in self.ROLE_ROUTES.items():
|
||||
for pattern in patterns:
|
||||
if re.match(pattern, path) and user_role != role and not request.user.is_superuser:
|
||||
return redirect(settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
# /admin/ is for the admin role. is_staff alone is not enough: an ops
|
||||
# or buyer account carrying the flag must not reach the
|
||||
# administration area, whose index embeds the model registry, and
|
||||
# where a later group or permission grant would be inherited. A
|
||||
# superuser (above) is the deliberate exception.
|
||||
if ADMIN_PATH.match(path) and user_role != "admin":
|
||||
return redirect(settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
if not user.is_staff:
|
||||
allowed = self.ROLE_ROUTES.get(user_role, [])
|
||||
if any(re.match(pattern, path) for pattern in allowed):
|
||||
return self.get_response(request)
|
||||
# The path belongs to a portal this role may not enter. Send them
|
||||
# to the role-aware landing view rather than straight into a
|
||||
# portal they cannot use. It used to redirect if *any* role's
|
||||
# pattern matched the path without belonging to this role, which
|
||||
# bounced a buyer off /portal/ itself (the admin patterns include
|
||||
# /portal/), and looped with the landing redirect.
|
||||
if any(
|
||||
re.match(pattern, path)
|
||||
for patterns in self.ROLE_ROUTES.values()
|
||||
for pattern in patterns
|
||||
):
|
||||
return redirect(settings.LOGIN_REDIRECT_URL)
|
||||
return self.get_response(request)
|
||||
|
||||
|
|
|
|||
26
apps/core/static/vendor/leaflet/LICENSE
vendored
Normal file
26
apps/core/static/vendor/leaflet/LICENSE
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
BSD 2-Clause License
|
||||
|
||||
Copyright (c) 2010-2023, Volodymyr Agafonkin
|
||||
Copyright (c) 2010-2011, CloudMade
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
64
apps/core/static/vendor/leaflet/PROVENANCE.md
vendored
Normal file
64
apps/core/static/vendor/leaflet/PROVENANCE.md
vendored
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Leaflet 1.9.4 — vendored front-end asset
|
||||
|
||||
Third-party code, self-hosted so the platform does not depend on a CDN for a
|
||||
component that renders supplier-location data.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Library | Leaflet (interactive maps) |
|
||||
| Version | **1.9.4** (latest stable; the 1.9 line is mature — pin it) |
|
||||
| Source | npm package `leaflet@1.9.4`, `dist/` |
|
||||
| Vendored | 2026-09-16 |
|
||||
| Licence | BSD-2-Clause — see `LICENSE` in this folder |
|
||||
| Upstream | https://github.com/Leaflet/Leaflet |
|
||||
|
||||
## Integrity
|
||||
|
||||
Both shipped files were validated against the SRI hashes published by the
|
||||
Leaflet project (leafletjs.com/download.html). They match, so these are the
|
||||
authentic 1.9.4 artefacts:
|
||||
|
||||
```
|
||||
sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo= leaflet.js
|
||||
sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY= leaflet.css
|
||||
```
|
||||
|
||||
Raw checksums (regenerate with `sha256sum leaflet.js leaflet.css images/*.png`):
|
||||
|
||||
```
|
||||
db49d009c841f5ca34a888c96511ae936fd9f5533e90d8b2c4d57596f4e5641a leaflet.js
|
||||
a7837102824184820dfa198d1ebcd109ff6d0ff9a2672a074b9a1b4d147d04c6 leaflet.css
|
||||
066daca850d8ffbef007af00b06eac0015728dee279c51f3cb6c716df7c42edf images/layers-2x.png
|
||||
1dbbe9d028e292f36fcba8f8b3a28d5e8932754fc2215b9ac69e4cdecf5107c6 images/layers.png
|
||||
00179c4c1ee830d3a108412ae0d294f55776cfeb085c60129a39aa6fc4ae2528 images/marker-icon-2x.png
|
||||
574c3a5cca85f4114085b6841596d62f00d7c892c7b03f28cbfa301deb1dc437 images/marker-icon.png
|
||||
264f5c640339f042dd729062cfc04c17f8ea0f29882b538e3848ed8f10edb4da images/marker-shadow.png
|
||||
```
|
||||
|
||||
## What is deliberately NOT here
|
||||
|
||||
Only `dist/` is vendored: `leaflet.js`, `leaflet.css` and `images/`. The source
|
||||
tree, the `.map` source maps and `leaflet-src.js` are omitted to keep the
|
||||
repository lean. Add a source map back only when debugging a specific issue.
|
||||
|
||||
## Upgrading
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/lf && cd /tmp/lf && npm install leaflet@<new-version>
|
||||
cp node_modules/leaflet/dist/leaflet.js node_modules/leaflet/dist/leaflet.css .
|
||||
cp node_modules/leaflet/dist/images/*.png images/
|
||||
cp node_modules/leaflet/LICENSE .
|
||||
sha256sum leaflet.js leaflet.css images/*.png # and update this file
|
||||
docker exec ixg-platform-app python manage.py collectstatic --noinput
|
||||
```
|
||||
|
||||
Check the new version's published SRI hashes match before shipping, and read the
|
||||
upstream changelog for breaking changes — a future major line is in development
|
||||
upstream, so do not jump to it without testing.
|
||||
|
||||
## Usage
|
||||
|
||||
Loaded by the Django admin (see `apps/compliance/admin.py`, `OriginZoneAdmin`)
|
||||
via the `Media` class, and drawn by
|
||||
`apps/compliance/static/js/ixg_zone_map.js`. The tile source is configurable —
|
||||
see the `LEAFLET_TILE_URL` setting in `dsp_platform/settings.py`.
|
||||
BIN
apps/core/static/vendor/leaflet/images/layers-2x.png
vendored
Normal file
BIN
apps/core/static/vendor/leaflet/images/layers-2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
BIN
apps/core/static/vendor/leaflet/images/layers.png
vendored
Normal file
BIN
apps/core/static/vendor/leaflet/images/layers.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 696 B |
BIN
apps/core/static/vendor/leaflet/images/marker-icon-2x.png
vendored
Normal file
BIN
apps/core/static/vendor/leaflet/images/marker-icon-2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
BIN
apps/core/static/vendor/leaflet/images/marker-icon.png
vendored
Normal file
BIN
apps/core/static/vendor/leaflet/images/marker-icon.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
apps/core/static/vendor/leaflet/images/marker-shadow.png
vendored
Normal file
BIN
apps/core/static/vendor/leaflet/images/marker-shadow.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 618 B |
661
apps/core/static/vendor/leaflet/leaflet.css
vendored
Normal file
661
apps/core/static/vendor/leaflet/leaflet.css
vendored
Normal file
|
|
@ -0,0 +1,661 @@
|
|||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
||||
6
apps/core/static/vendor/leaflet/leaflet.js
vendored
Normal file
6
apps/core/static/vendor/leaflet/leaflet.js
vendored
Normal file
File diff suppressed because one or more lines are too long
854
apps/core/tests.py
Normal file
854
apps/core/tests.py
Normal file
|
|
@ -0,0 +1,854 @@
|
|||
"""Cross-cutting checks: every portal page renders, map tiles are served
|
||||
from our own origin, and the training data the walkthrough depends on is
|
||||
coherent.
|
||||
|
||||
None of these were covered before. The page sweep exists because a view can lose
|
||||
a line — a `template_name`, say — and 500 in front of a user while the rest of the
|
||||
suite stays green; it fetches each read-only page with an empty database, so it
|
||||
covers the empty-state branches too. The tile tests cover the map's only external
|
||||
dependency, which used to be fetched by the viewer's browser. The seed tests cover
|
||||
the exception branch, which used to depend on a record that happened to exist
|
||||
already, with a stored status that contradicted its own values.
|
||||
"""
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.error
|
||||
from decimal import Decimal
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.test import Client, SimpleTestCase, TestCase, override_settings
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
from apps.capa.models import CAPARecord
|
||||
from apps.compliance.models import (
|
||||
DueDiligenceCase,
|
||||
EUDREvidence,
|
||||
HarvestIntake,
|
||||
IntakeBatchLineage,
|
||||
ProductSpecification,
|
||||
)
|
||||
from apps.notifications.models import Notification
|
||||
from apps.qc.models import QCResult
|
||||
from apps.shipments.models import Shipment
|
||||
from apps.subscriptions.models import Subscription
|
||||
|
||||
from .tiles import browser_tile_url, upstream_url
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
OPS_ROUTES = [
|
||||
"/ops/",
|
||||
"/ops/intakes/",
|
||||
"/ops/intakes/new/",
|
||||
"/ops/batches/",
|
||||
"/ops/batches/new/",
|
||||
"/ops/qc/",
|
||||
"/ops/qc/verify/",
|
||||
"/ops/coa/",
|
||||
"/ops/shipments/",
|
||||
"/ops/shipments/new/",
|
||||
"/ops/capa/",
|
||||
"/ops/documents/",
|
||||
"/ops/esg/",
|
||||
"/ops/eudr/",
|
||||
"/ops/help/",
|
||||
]
|
||||
|
||||
PORTAL_ROUTES = [
|
||||
"/portal/",
|
||||
"/portal/batches/",
|
||||
"/portal/documents/",
|
||||
"/portal/compliance/",
|
||||
"/portal/subscription/",
|
||||
"/portal/help/",
|
||||
]
|
||||
|
||||
|
||||
def make_sweep_batch():
|
||||
"""A batch with one lineage row, so the edit and lineage screens render.
|
||||
|
||||
The route sweeps otherwise run against an empty database and never render
|
||||
``batch_form.html`` in edit mode or ``batch_lineage.html`` at all — only the
|
||||
source guard covers them, and that is the weaker guard.
|
||||
"""
|
||||
batch = ProcessingBatch.objects.create(batch_reference="IXG-SWEEP-001")
|
||||
intake = HarvestIntake.objects.create(
|
||||
intake_reference="IXG-SWEEP-INTAKE-001", weight_kg=Decimal("100.00")
|
||||
)
|
||||
IntakeBatchLineage.objects.create(
|
||||
batch=batch, harvest_intake=intake, weight_allocated_kg=Decimal("40.00")
|
||||
)
|
||||
return batch
|
||||
|
||||
|
||||
def batch_screens(batch):
|
||||
return [
|
||||
f"/ops/batches/{batch.pk}/",
|
||||
f"/ops/batches/{batch.pk}/edit/",
|
||||
f"/ops/batches/{batch.pk}/lineage/",
|
||||
]
|
||||
|
||||
|
||||
def make_sweep_shipment(batch):
|
||||
"""A shipment so the detail and edit screens render in the route sweeps."""
|
||||
return Shipment.objects.create(
|
||||
batch=batch,
|
||||
status=Shipment.Status.BOOKED,
|
||||
vessel_name="MV Sweep",
|
||||
container_ref="IXGU0000000",
|
||||
etd_nigeria="2026-09-01",
|
||||
eta_uk="2026-10-01",
|
||||
)
|
||||
|
||||
|
||||
def shipment_screens(shipment):
|
||||
return [
|
||||
f"/ops/shipments/{shipment.pk}/",
|
||||
f"/ops/shipments/{shipment.pk}/edit/",
|
||||
]
|
||||
|
||||
|
||||
class PortalPagesRenderTests(TestCase):
|
||||
def make_user(self, email, role):
|
||||
return User.objects.create_user(
|
||||
email=email, password="pass1234", role=role,
|
||||
full_name=email.split("@")[0], is_staff=True,
|
||||
)
|
||||
|
||||
def sweep(self, user, routes, host):
|
||||
client = Client(SERVER_NAME=host)
|
||||
client.force_login(user)
|
||||
for route in routes:
|
||||
with self.subTest(route=route):
|
||||
response = client.get(route)
|
||||
body = response.content
|
||||
self.assertEqual(response.status_code, 200, f"{route} did not render")
|
||||
for marker in (b"Traceback", b"TemplateDoesNotExist", b"Django Version"):
|
||||
self.assertNotIn(marker, body, f"{route} rendered an error page")
|
||||
|
||||
def test_ops_pages_render(self):
|
||||
batch = make_sweep_batch()
|
||||
shipment = make_sweep_shipment(batch)
|
||||
routes = OPS_ROUTES + batch_screens(batch) + shipment_screens(shipment)
|
||||
self.sweep(self.make_user("ops-sweep@ixg.local", "ops"), routes, "ops.ixgltd.com")
|
||||
|
||||
def test_buyer_pages_render(self):
|
||||
self.sweep(self.make_user("buyer-sweep@ixg.local", "buyer"), PORTAL_ROUTES, "portal.ixgltd.com")
|
||||
|
||||
def test_the_public_signup_page_renders_for_visitors(self):
|
||||
response = Client(SERVER_NAME="portal.ixgltd.com").get("/portal/signup/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class RoleRoutingTests(TestCase):
|
||||
"""A buyer is not staff, so the middleware must not bounce them off /portal/.
|
||||
|
||||
The old middleware redirected if any role's pattern matched the path without
|
||||
belonging to the user's role, and the admin patterns include /portal/ — so a
|
||||
real (non-staff) buyer was sent to the landing view, which sent them back.
|
||||
These pin the corrected routing: each role reaches its own portal and is
|
||||
turned away from the others via the landing view.
|
||||
"""
|
||||
|
||||
def make_user(self, email, role):
|
||||
return User.objects.create_user(
|
||||
email=email, password="pass1234", role=role, is_staff=False
|
||||
)
|
||||
|
||||
def test_a_buyer_reaches_the_buyer_portal(self):
|
||||
client = Client(SERVER_NAME="portal.ixgltd.com")
|
||||
client.force_login(self.make_user("route-buyer@ixg.local", "buyer"))
|
||||
self.assertEqual(client.get("/portal/").status_code, 200)
|
||||
|
||||
def test_a_buyer_is_turned_away_from_ops_and_admin(self):
|
||||
client = Client(SERVER_NAME="portal.ixgltd.com")
|
||||
client.force_login(self.make_user("route-buyer2@ixg.local", "buyer"))
|
||||
for path in ("/ops/", "/admin/"):
|
||||
with self.subTest(path=path):
|
||||
response = client.get(path)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response["Location"], "/")
|
||||
|
||||
def test_ops_reaches_ops(self):
|
||||
client = Client(SERVER_NAME="ops.ixgltd.com")
|
||||
client.force_login(self.make_user("route-ops@ixg.local", "ops"))
|
||||
self.assertEqual(client.get("/ops/").status_code, 200)
|
||||
|
||||
def test_the_landing_view_sends_each_role_to_its_portal(self):
|
||||
cases = [("buyer", "/portal/"), ("ops", "/ops/"), ("admin", "/admin/")]
|
||||
for role, destination in cases:
|
||||
with self.subTest(role=role):
|
||||
client = Client(SERVER_NAME="app.ixgltd.com")
|
||||
client.force_login(self.make_user(f"landing-{role}@ixg.local", role))
|
||||
response = client.get("/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response["Location"], destination)
|
||||
|
||||
def test_a_visitor_is_sent_to_sign_in(self):
|
||||
response = Client(SERVER_NAME="app.ixgltd.com").get("/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/oidc/authenticate/", response["Location"])
|
||||
|
||||
|
||||
class AdminAreaRoleTests(TestCase):
|
||||
"""The administration area is for the admin role, not for the staff flag.
|
||||
|
||||
A staff flag on an ops or buyer account used to be enough to reach /admin/,
|
||||
whose index embeds the model registry, and where a later group or permission
|
||||
grant would be inherited. Staff status is derived from the role, but the gate
|
||||
must not rest on the flag alone.
|
||||
"""
|
||||
|
||||
def make_user(self, email, role, is_staff):
|
||||
return User.objects.create_user(
|
||||
email=email, password="pass1234", role=role, is_staff=is_staff
|
||||
)
|
||||
|
||||
def test_a_staff_ops_account_is_turned_away_from_admin(self):
|
||||
client = Client(SERVER_NAME="app.ixgltd.com")
|
||||
client.force_login(self.make_user("gate-ops@ixg.local", "ops", True))
|
||||
response = client.get("/admin/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response["Location"], "/")
|
||||
|
||||
def test_a_staff_buyer_account_is_turned_away_from_admin(self):
|
||||
client = Client(SERVER_NAME="app.ixgltd.com")
|
||||
client.force_login(self.make_user("gate-buyer@ixg.local", "buyer", True))
|
||||
response = client.get("/admin/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response["Location"], "/")
|
||||
|
||||
def test_an_admin_role_reaches_admin(self):
|
||||
client = Client(SERVER_NAME="app.ixgltd.com")
|
||||
client.force_login(self.make_user("gate-admin@ixg.local", "admin", True))
|
||||
self.assertEqual(client.get("/admin/").status_code, 200)
|
||||
|
||||
def test_a_superuser_reaches_admin(self):
|
||||
client = Client(SERVER_NAME="app.ixgltd.com")
|
||||
client.force_login(
|
||||
User.objects.create_superuser(
|
||||
email="gate-super@ixg.local", password="pass1234"
|
||||
)
|
||||
)
|
||||
self.assertEqual(client.get("/admin/").status_code, 200)
|
||||
|
||||
def test_the_ops_sidebar_hides_admin_from_a_non_admin(self):
|
||||
client = Client(SERVER_NAME="ops.ixgltd.com")
|
||||
client.force_login(self.make_user("gate-ops2@ixg.local", "ops", True))
|
||||
self.assertNotContains(client.get("/ops/"), 'href="/admin/"')
|
||||
|
||||
def test_the_ops_sidebar_shows_admin_to_an_admin(self):
|
||||
client = Client(SERVER_NAME="ops.ixgltd.com")
|
||||
client.force_login(self.make_user("gate-admin2@ixg.local", "admin", True))
|
||||
self.assertContains(client.get("/ops/"), 'href="/admin/"')
|
||||
|
||||
|
||||
class ApiPermissionBoundaryTests(TestCase):
|
||||
"""The API permission boundary, pinned for every viewset.
|
||||
|
||||
Batch writes were closed first; this sweeps the rest. A buyer account is the
|
||||
adversary: it is a legitimate, authenticated user, so ``IsAuthenticated``
|
||||
alone lets it through. Anything that changes a record — a document, a QC
|
||||
result, a certificate, a shipment status — must be ops/admin only. The buyer
|
||||
portal is server-rendered through the ORM, so it needs no API access; the one
|
||||
exception is the AI chat, which the buyer portal calls from the browser.
|
||||
"""
|
||||
|
||||
# Every ModelViewSet that changes a record. A buyer must get 403 on write.
|
||||
OPS_WRITE_ENDPOINTS = [
|
||||
"/api/batches/",
|
||||
"/api/qc-results/",
|
||||
"/api/shipments/",
|
||||
"/api/documents/",
|
||||
"/api/certificates-of-analysis/",
|
||||
"/api/regulatory-certs/",
|
||||
"/api/capa-records/",
|
||||
"/api/esg-reports/",
|
||||
"/api/agents/",
|
||||
"/api/product-specs/",
|
||||
"/api/facilities/",
|
||||
"/api/origin-zones/",
|
||||
"/api/collector-groups/",
|
||||
"/api/harvest-intakes/",
|
||||
"/api/intake-batch-lineage/",
|
||||
"/api/eudr-cases/",
|
||||
"/api/eudr-evidence/",
|
||||
"/api/risk-assessments/",
|
||||
"/api/mitigation-actions/",
|
||||
"/api/dds-references/",
|
||||
]
|
||||
|
||||
# Reads a buyer may still make: the chat it calls from the browser, and the
|
||||
# user/notification/subscription rows scoped to the buyer itself.
|
||||
BUYER_READABLE_ENDPOINTS = [
|
||||
"/api/agents/",
|
||||
"/api/ai-interactions/",
|
||||
"/api/users/",
|
||||
"/api/notifications/",
|
||||
"/api/subscriptions/",
|
||||
]
|
||||
|
||||
def make_user(self, email, role):
|
||||
return User.objects.create_user(
|
||||
email=email, password="pass1234", role=role,
|
||||
full_name=email.split("@")[0], is_staff=True,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
self.buyer = self.make_user("boundary-buyer@ixg.local", "buyer")
|
||||
self.other_buyer = self.make_user("boundary-other@ixg.local", "buyer")
|
||||
self.ops = self.make_user("boundary-ops@ixg.local", "ops")
|
||||
self.client = APIClient()
|
||||
|
||||
def test_a_buyer_cannot_write_to_any_ops_api(self):
|
||||
self.client.force_authenticate(user=self.buyer)
|
||||
for endpoint in self.OPS_WRITE_ENDPOINTS:
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertEqual(
|
||||
self.client.post(endpoint, {}, format="json").status_code, 403
|
||||
)
|
||||
|
||||
def test_a_buyer_cannot_read_the_ops_apis(self):
|
||||
self.client.force_authenticate(user=self.buyer)
|
||||
for endpoint in self.OPS_WRITE_ENDPOINTS:
|
||||
if endpoint in self.BUYER_READABLE_ENDPOINTS:
|
||||
continue
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertEqual(self.client.get(endpoint).status_code, 403)
|
||||
|
||||
def test_ops_is_not_forbidden_on_the_ops_apis(self):
|
||||
self.client.force_authenticate(user=self.ops)
|
||||
for endpoint in self.OPS_WRITE_ENDPOINTS:
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertNotEqual(self.client.get(endpoint).status_code, 403)
|
||||
|
||||
def test_a_buyer_can_still_reach_the_chat_endpoints(self):
|
||||
self.client.force_authenticate(user=self.buyer)
|
||||
for endpoint in self.BUYER_READABLE_ENDPOINTS:
|
||||
with self.subTest(endpoint=endpoint):
|
||||
self.assertNotEqual(self.client.get(endpoint).status_code, 403)
|
||||
response = self.client.post("/api/ai-interactions/chat/", {}, format="json")
|
||||
self.assertNotEqual(response.status_code, 403)
|
||||
|
||||
def test_a_buyer_cannot_promote_themselves_to_admin(self):
|
||||
self.client.force_authenticate(user=self.buyer)
|
||||
response = self.client.patch(
|
||||
f"/api/users/{self.buyer.id}/", {"role": "admin"}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
self.buyer.refresh_from_db()
|
||||
self.assertEqual(self.buyer.role, "buyer")
|
||||
|
||||
def test_the_user_api_shows_a_buyer_only_themselves(self):
|
||||
self.client.force_authenticate(user=self.buyer)
|
||||
response = self.client.get("/api/users/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
rows = body["results"] if isinstance(body, dict) else body
|
||||
self.assertEqual([row["email"] for row in rows], [self.buyer.email])
|
||||
|
||||
def test_a_user_cannot_reach_another_users_rows(self):
|
||||
note = Notification.objects.create(
|
||||
user=self.other_buyer, title="Other", message="Other"
|
||||
)
|
||||
sub = Subscription.objects.create(
|
||||
user=self.other_buyer, plan=Subscription.Plan.STARTER
|
||||
)
|
||||
self.client.force_authenticate(user=self.buyer)
|
||||
self.assertEqual(
|
||||
self.client.get(f"/api/notifications/{note.id}/").status_code, 404
|
||||
)
|
||||
self.assertEqual(
|
||||
self.client.get(f"/api/subscriptions/{sub.id}/").status_code, 404
|
||||
)
|
||||
|
||||
|
||||
class TileProxyTests(TestCase):
|
||||
"""Tiles come from our origin, so the map does not depend on the viewer's
|
||||
network being able to reach a third-party tile server."""
|
||||
|
||||
def setUp(self):
|
||||
self.cache = Path(tempfile.mkdtemp(prefix="ixg-tile-cache-"))
|
||||
self.addCleanup(shutil.rmtree, self.cache, True)
|
||||
override = override_settings(LEAFLET_TILE_CACHE_DIR=str(self.cache))
|
||||
override.enable()
|
||||
self.addCleanup(override.disable)
|
||||
|
||||
self.user = User.objects.create_user(
|
||||
email="tile-viewer@ixg.local", password="pass1234", role="admin",
|
||||
full_name="Tile Viewer", is_staff=True,
|
||||
)
|
||||
self.anon = Client(SERVER_NAME="app.ixgltd.com")
|
||||
self.viewer = Client(SERVER_NAME="app.ixgltd.com")
|
||||
self.viewer.force_login(self.user)
|
||||
|
||||
@staticmethod
|
||||
def fake_upstream(payload):
|
||||
"""Stand in for urllib.request.urlopen returning a tile."""
|
||||
response = MagicMock()
|
||||
response.read.return_value = payload
|
||||
context = MagicMock()
|
||||
context.__enter__.return_value = response
|
||||
return lambda *a, **k: context
|
||||
|
||||
def test_a_viewer_must_be_signed_in(self):
|
||||
self.assertEqual(self.anon.get("/tiles/13/4085/3448.png").status_code, 403)
|
||||
|
||||
def test_tiles_outside_the_zoom_range_are_refused(self):
|
||||
self.assertEqual(self.viewer.get("/tiles/20/1/1.png").status_code, 404)
|
||||
# x beyond the span of that zoom level
|
||||
self.assertEqual(self.viewer.get("/tiles/1/5/1.png").status_code, 404)
|
||||
|
||||
def test_a_tile_is_fetched_from_the_upstream_and_served(self):
|
||||
payload = b"\x89PNG\r\n\x1a\n fake tile"
|
||||
with patch("apps.core.tiles.urllib.request.urlopen", self.fake_upstream(payload)):
|
||||
r = self.viewer.get("/tiles/13/4085/3448.png")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r["Content-Type"], "image/png")
|
||||
self.assertEqual(r.content, payload)
|
||||
self.assertTrue((self.cache / "13" / "4085" / "3448.png").is_file())
|
||||
|
||||
def test_a_second_request_is_served_from_the_cache(self):
|
||||
payload = b"\x89PNG\r\n\x1a\n cached tile"
|
||||
first = MagicMock(side_effect=self.fake_upstream(payload))
|
||||
with patch("apps.core.tiles.urllib.request.urlopen", first):
|
||||
self.viewer.get("/tiles/13/4085/3448.png")
|
||||
self.assertEqual(first.call_count, 1)
|
||||
|
||||
with patch("apps.core.tiles.urllib.request.urlopen") as second:
|
||||
r = self.viewer.get("/tiles/13/4085/3448.png")
|
||||
self.assertEqual(second.call_count, 0, "cache should have been used")
|
||||
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.content, payload)
|
||||
|
||||
def test_an_unreachable_upstream_answers_an_empty_tile_not_an_error_page(self):
|
||||
with patch(
|
||||
"apps.core.tiles.urllib.request.urlopen",
|
||||
side_effect=urllib.error.HTTPError("u", 403, "Forbidden", None, None),
|
||||
):
|
||||
r = self.viewer.get("/tiles/13/4085/3448.png")
|
||||
self.assertEqual(r.status_code, 204)
|
||||
self.assertEqual(r.content, b"")
|
||||
|
||||
def test_browser_tile_url_is_same_origin_when_the_proxy_is_on(self):
|
||||
self.assertEqual(browser_tile_url(), "/tiles/{z}/{x}/{y}.png")
|
||||
|
||||
def test_browser_tile_url_is_the_upstream_when_the_proxy_is_off(self):
|
||||
with override_settings(LEAFLET_TILE_PROXY_ENABLED=False):
|
||||
self.assertEqual(browser_tile_url(), settings.LEAFLET_TILE_URL)
|
||||
|
||||
def test_the_upstream_url_gets_the_coordinates_substituted(self):
|
||||
with override_settings(
|
||||
LEAFLET_TILE_URL="https://example.invalid/{s}/{z}/{x}/{y}.png"
|
||||
):
|
||||
self.assertEqual(
|
||||
upstream_url(13, 4085, 3448),
|
||||
"https://example.invalid/a/13/4085/3448.png",
|
||||
)
|
||||
|
||||
|
||||
class SeedWalkthroughTests(TestCase):
|
||||
"""The walkthrough's exception branch is training data, and it has to hold
|
||||
together: a failure the platform's own evaluator would recognise, a batch that
|
||||
follows it, and one CAPA — reproducible on an empty database rather than
|
||||
depending on a record that happened to exist already."""
|
||||
|
||||
def setUp(self):
|
||||
self.media = Path(tempfile.mkdtemp(prefix="ixg-seed-media-"))
|
||||
self.addCleanup(shutil.rmtree, self.media, True)
|
||||
override = override_settings(MEDIA_ROOT=self.media)
|
||||
override.enable()
|
||||
self.addCleanup(override.disable)
|
||||
|
||||
# The seed uses the accounts that already exist rather than inventing staff.
|
||||
User.objects.create_user(
|
||||
email="test@ixg.local", password="pass1234", role="ops",
|
||||
full_name="Test User", is_staff=True,
|
||||
)
|
||||
User.objects.create_user(
|
||||
email="dbg@ixg.local", password="pass1234", role="ops",
|
||||
full_name="Debug User", is_staff=True,
|
||||
)
|
||||
call_command("seed_walkthrough", verbosity=0)
|
||||
|
||||
def test_the_exception_branch_is_reproducible(self):
|
||||
batch = ProcessingBatch.objects.get(batch_reference="IXG-TEST-001")
|
||||
self.assertEqual(batch.status, ProcessingBatch.Status.QC_FAIL)
|
||||
self.assertFalse(batch.intake_lineage.exists(), "the example has no lineage")
|
||||
|
||||
qc = QCResult.objects.get(batch=batch)
|
||||
self.assertFalse(qc.overall_pass)
|
||||
self.assertEqual(qc.result_status, QCResult.ResultStatus.OUTSIDE_SPEC)
|
||||
self.assertTrue(qc.is_quarantine, "a failing result quarantines the batch")
|
||||
self.assertEqual(qc.verification_status, QCResult.VerificationStatus.DRAFT)
|
||||
self.assertIsNone(qc.verified_by, "it must still need a second pair of eyes")
|
||||
|
||||
def test_the_stored_status_cannot_contradict_the_stored_values(self):
|
||||
qc = QCResult.objects.get(batch__batch_reference="IXG-TEST-001")
|
||||
limit = ProductSpecification.current("Unrefined Shea Butter").parameters.get(
|
||||
parameter="moisture"
|
||||
).max_value
|
||||
self.assertGreater(
|
||||
qc.moisture_percentage, limit,
|
||||
"the failure must be a genuine one, judged by the real evaluator",
|
||||
)
|
||||
|
||||
def test_the_passing_batches_stay_within_specification(self):
|
||||
for reference in ("IXG-BATCH-2026-014", "IXG-BATCH-2026-005"):
|
||||
with self.subTest(batch=reference):
|
||||
qc = QCResult.objects.get(batch__batch_reference=reference)
|
||||
self.assertTrue(qc.overall_pass)
|
||||
self.assertEqual(qc.result_status, QCResult.ResultStatus.WITHIN_SPEC)
|
||||
self.assertFalse(qc.is_quarantine)
|
||||
|
||||
def test_one_capa_per_demo_batch(self):
|
||||
capas = CAPARecord.objects.filter(batch__batch_reference="IXG-TEST-001")
|
||||
self.assertEqual(capas.count(), 1)
|
||||
self.assertEqual(
|
||||
capas.first().issue_description, "QC failure on batch IXG-TEST-001"
|
||||
)
|
||||
|
||||
def test_re_running_the_seed_does_not_duplicate_records(self):
|
||||
call_command("seed_walkthrough", verbosity=0)
|
||||
self.assertEqual(
|
||||
QCResult.objects.filter(batch__batch_reference="IXG-TEST-001").count(), 1
|
||||
)
|
||||
self.assertEqual(CAPARecord.objects.count(), 1)
|
||||
self.assertEqual(ProcessingBatch.objects.filter(batch_reference="IXG-TEST-001").count(), 1)
|
||||
|
||||
def test_the_seeded_buyer_is_not_staff(self):
|
||||
"""Staff status is derived from the role; the seed must not disagree."""
|
||||
buyer = User.objects.get(email="buyer@greenfieldfoods.example")
|
||||
self.assertFalse(buyer.is_staff)
|
||||
|
||||
def test_re_running_the_seed_corrects_a_staff_buyer(self):
|
||||
"""The seed used to set is_staff only on creation, so a buyer whose flag
|
||||
had drifted to True stayed inside the admin area until its next sign-in.
|
||||
Re-running the seed must correct it, not reopen the window."""
|
||||
User.objects.filter(email="buyer@greenfieldfoods.example").update(is_staff=True)
|
||||
call_command("seed_walkthrough", verbosity=0)
|
||||
buyer = User.objects.get(email="buyer@greenfieldfoods.example")
|
||||
self.assertFalse(buyer.is_staff)
|
||||
|
||||
def test_every_intake_keeps_within_what_it_received(self):
|
||||
"""The rule the ops lineage screen enforces, applied to the training data.
|
||||
|
||||
It was not applied here, and nobody noticed: both demo batches were traced
|
||||
back to the same three intakes, so the 820 kg intake 0141 was allocated
|
||||
800 kg twice over. The screen now refuses that, so the data must not need it.
|
||||
"""
|
||||
for intake in HarvestIntake.objects.all():
|
||||
allocated = sum(row.weight_allocated_kg or 0 for row in intake.batch_lineage.all())
|
||||
with self.subTest(intake=intake.intake_reference):
|
||||
self.assertLessEqual(
|
||||
allocated, intake.weight_kg,
|
||||
f"{intake.intake_reference} received {intake.weight_kg} kg "
|
||||
f"and is allocated {allocated} kg",
|
||||
)
|
||||
|
||||
def test_the_two_passing_batches_have_their_own_intakes(self):
|
||||
completed = set(
|
||||
ProcessingBatch.objects.get(batch_reference="IXG-BATCH-2026-014")
|
||||
.intake_lineage.values_list("harvest_intake__intake_reference", flat=True)
|
||||
)
|
||||
inflight = set(
|
||||
ProcessingBatch.objects.get(batch_reference="IXG-BATCH-2026-005")
|
||||
.intake_lineage.values_list("harvest_intake__intake_reference", flat=True)
|
||||
)
|
||||
self.assertTrue(completed, "the completed batch needs a lineage to show")
|
||||
self.assertTrue(inflight, "the in-flight batch needs a lineage to show")
|
||||
self.assertEqual(
|
||||
completed & inflight, set(),
|
||||
"an intake cannot supply two batches' worth of shea",
|
||||
)
|
||||
|
||||
def test_re_running_the_seed_removes_records_it_no_longer_makes(self):
|
||||
batch = ProcessingBatch.objects.get(batch_reference="IXG-BATCH-2026-005")
|
||||
stray = HarvestIntake.objects.get(intake_reference="IXG-INTAKE-2026-0141")
|
||||
IntakeBatchLineage.objects.create(
|
||||
batch=batch, harvest_intake=stray, weight_allocated_kg=Decimal("10.00")
|
||||
)
|
||||
# Evidence is keyed on the filename, so reassigning the in-flight batch's
|
||||
# intakes used to leave the old harvest_document row in place.
|
||||
case = DueDiligenceCase.objects.get(batch=batch)
|
||||
stale = EUDREvidence.objects.create(
|
||||
case=case,
|
||||
evidence_type=EUDREvidence.EvidenceType.HARVEST_DOCUMENT,
|
||||
file="IXG-INTAKE-2026-0141-weighbridge-ticket.pdf",
|
||||
)
|
||||
call_command("seed_walkthrough", verbosity=0)
|
||||
self.assertFalse(
|
||||
batch.intake_lineage.filter(harvest_intake=stray).exists(),
|
||||
"a link the seed no longer makes must not survive a re-run",
|
||||
)
|
||||
self.assertEqual(batch.intake_lineage.count(), 3)
|
||||
self.assertFalse(
|
||||
EUDREvidence.objects.filter(pk=stale.pk).exists(),
|
||||
"stale evidence the seed no longer references must not survive a re-run",
|
||||
)
|
||||
self.assertEqual(case.evidence.count(), 4)
|
||||
|
||||
|
||||
class RawTagScanner(HTMLParser):
|
||||
"""Collect each start tag's raw source text, quote-aware.
|
||||
|
||||
A regex such as ``<[^>]*>`` ends the tag at the first ``>``, which a quoted
|
||||
attribute value may contain: ``<span title="a > b" class="badge" bg-success>``
|
||||
then arrives truncated, and a real leak after the quote is never seen.
|
||||
``html.parser`` knows where the tag actually ends.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=False)
|
||||
self.tags = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.tags.append((self.getpos()[0], self.get_starttag_text()))
|
||||
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
self.tags.append((self.getpos()[0], self.get_starttag_text()))
|
||||
|
||||
|
||||
def scan_tags(html):
|
||||
"""Return ``(line, raw_tag)`` for every start tag in ``html``."""
|
||||
scanner = RawTagScanner()
|
||||
scanner.feed(html)
|
||||
scanner.close()
|
||||
return scanner.tags
|
||||
|
||||
|
||||
class TemplateAttributeHygieneTests(TestCase):
|
||||
"""No ops or buyer page may render an unquoted attribute that holds more than
|
||||
one token.
|
||||
|
||||
An unquoted attribute value ends at the first whitespace, so
|
||||
``class=badge bg-danger`` reaches the browser as ``class="badge"`` plus two
|
||||
meaningless attributes: the badge renders white-on-white and the failure is
|
||||
invisible. Every ops template shipped that way for months, so this asserts it
|
||||
on the *rendered* pages — the only place the question has an unambiguous
|
||||
answer, since a template may contain ``{{ }}`` in an unquoted attribute that
|
||||
renders harmlessly.
|
||||
"""
|
||||
|
||||
DETECTOR = re.compile(r'\s([\w-]+)=([^\s"\'=<>`]+(?:\s+[^\s"\'=<>`]+)+)(?=[\s>])')
|
||||
|
||||
@classmethod
|
||||
def offenders(cls, html):
|
||||
found = []
|
||||
for _, tag in scan_tags(html):
|
||||
for match in cls.DETECTOR.finditer(tag):
|
||||
found.append(match.group(0).strip())
|
||||
return found
|
||||
|
||||
def test_the_detector_catches_the_real_defect(self):
|
||||
bad = '<span class=badge bg-danger>Fail</span>'
|
||||
self.assertTrue(self.DETECTOR.search(scan_tags(bad)[0][1]))
|
||||
good = '<span class="badge bg-danger">Fail</span>'
|
||||
self.assertFalse(self.DETECTOR.search(scan_tags(good)[0][1]))
|
||||
|
||||
def test_a_quoted_angle_bracket_does_not_hide_a_defect(self):
|
||||
# The old ``<[^>]*>`` tag regex stopped at the ``>`` inside the title, so
|
||||
# the unquoted attribute after it was never examined.
|
||||
unquoted = '<span title="a > b" class=badge bg-danger>Fail</span>'
|
||||
self.assertTrue(self.offenders(unquoted))
|
||||
leaked = '<span title="a > b" class="badge" bg-success>Approved</span>'
|
||||
self.assertEqual(self.leaked_classes(leaked), ["bg-success"])
|
||||
|
||||
# Two more defects from the same family, both of which shipped and neither of
|
||||
# which any status code, test or screenshot review caught.
|
||||
#
|
||||
# 1. The *inverse* of the mistake above, made when the unquoted attributes were
|
||||
# fixed in bulk: the quotes went around two attributes instead of around one
|
||||
# value, so `method=post novalidate` became `method="post novalidate"`. A
|
||||
# form's method is then an unrecognised keyword, the browser falls back to
|
||||
# GET, and the form silently saves nothing.
|
||||
# 2. A conditional class list written *outside* the quotes —
|
||||
# `<span class="badge" {% if x %}bg-success{% else %}bg-danger{% endif %}>`
|
||||
# — leaves the colour as a bare attribute. Bootstrap's `.badge` on its own
|
||||
# is white text on a transparent background, so the status is invisible.
|
||||
FORM_METHOD = re.compile(r'<form[^>]*\bmethod="([^"]*)"')
|
||||
|
||||
ATTRIBUTE_NAMES = {
|
||||
"post", "get", "dialog", "novalidate", "async", "defer", "multiple",
|
||||
"required", "checked", "selected", "disabled", "readonly", "autofocus",
|
||||
"hidden", "target", "method", "action", "enctype", "accept",
|
||||
}
|
||||
|
||||
# Any bare hyphenated token is a class that has escaped its quotes unless it
|
||||
# is an attribute that legitimately carries no value. An allowlist of known
|
||||
# Bootstrap prefixes missed the rest of the library (``form-control``,
|
||||
# ``alert-danger``, ``is-invalid``, ``rounded-*``, ``shadow-*``); the default
|
||||
# has to be "leak". Class lists inside quotes may contain spaces
|
||||
# legitimately, so only value-less tokens are considered.
|
||||
VALUELESS_ATTRIBUTES = ATTRIBUTE_NAMES | {
|
||||
"allowfullscreen", "autoplay", "controls", "default", "inert", "ismap",
|
||||
"itemscope", "loop", "muted", "nomodule", "open", "playsinline",
|
||||
"reversed", "truespeed",
|
||||
# Hyphenated boolean attributes from common front-end libraries.
|
||||
"x-cloak", "x-ignore", "x-transition",
|
||||
}
|
||||
|
||||
QUOTED_VALUE = re.compile(r'\s([\w-]+)="([^"]*)"')
|
||||
ANY_QUOTED = re.compile(r'"[^"]*"|\'[^\']*\'')
|
||||
|
||||
@classmethod
|
||||
def is_leaked_class(cls, token):
|
||||
return (
|
||||
"-" in token
|
||||
and "=" not in token
|
||||
and token.lower() not in cls.VALUELESS_ATTRIBUTES
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def folded_attributes(cls, html):
|
||||
"""Quoted values that are really two attributes run together."""
|
||||
found = []
|
||||
for _, tag in scan_tags(html):
|
||||
for name, value in cls.QUOTED_VALUE.findall(tag):
|
||||
tokens = value.split()
|
||||
if len(tokens) > 1 and all(t in cls.ATTRIBUTE_NAMES for t in tokens):
|
||||
found.append(f"{name}={value!r}")
|
||||
return found
|
||||
|
||||
@classmethod
|
||||
def leaked_classes(cls, html):
|
||||
"""Classes sitting outside their class attribute."""
|
||||
found = []
|
||||
for _, tag in scan_tags(html):
|
||||
# Drop the angle brackets first: with them left on, the last token in
|
||||
# the tag arrives as "bg-success>" and matches nothing.
|
||||
without_values = cls.ANY_QUOTED.sub(" ", tag[1:-1])
|
||||
for token in without_values.split():
|
||||
if cls.is_leaked_class(token):
|
||||
found.append(token)
|
||||
return found
|
||||
|
||||
def test_the_folded_attribute_detector_catches_the_real_defect(self):
|
||||
self.assertTrue(self.folded_attributes('<form method="post novalidate">'))
|
||||
for good in (
|
||||
'<form method="post" novalidate>',
|
||||
'<div class="badge bg-danger">',
|
||||
'<meta content="width=device-width, initial-scale=1">',
|
||||
'<input type="checkbox" checked disabled>',
|
||||
):
|
||||
with self.subTest(good=good):
|
||||
self.assertEqual(self.folded_attributes(good), [])
|
||||
|
||||
def test_the_leaked_class_detector_catches_the_real_defect(self):
|
||||
# The first is the defect that shipped; the rest are from the same family
|
||||
# and were missed by the old Bootstrap-prefix allowlist.
|
||||
cases = {
|
||||
'<span class="badge" bg-success>Approved</span>': "bg-success",
|
||||
'<div class="form-group" form-control>': "form-control",
|
||||
'<div class="alert" alert-danger>': "alert-danger",
|
||||
'<input class="form-control" is-invalid>': "is-invalid",
|
||||
'<div class="card" rounded-3>': "rounded-3",
|
||||
'<div class="card" shadow-sm>': "shadow-sm",
|
||||
}
|
||||
for bad, expected in cases.items():
|
||||
with self.subTest(bad=bad):
|
||||
self.assertIn(expected, self.leaked_classes(bad))
|
||||
|
||||
def test_the_leaked_class_detector_leaves_legitimate_markup_alone(self):
|
||||
for good in (
|
||||
'<span class="badge bg-success">Approved</span>',
|
||||
'<div class="row g-3 mb-4">',
|
||||
'<table class="table table-sm mb-0">',
|
||||
'<td colspan="3" class="text-end text-secondary">total</td>',
|
||||
'<select class="form-select" required>',
|
||||
'<div class="stat-card p-0">',
|
||||
'<input type="checkbox" checked disabled>',
|
||||
'<form method="post" novalidate>',
|
||||
'<div x-cloak>hidden until ready</div>',
|
||||
):
|
||||
with self.subTest(good=good):
|
||||
self.assertEqual(self.leaked_classes(good), [])
|
||||
|
||||
def check_pages(self, user, routes, host):
|
||||
"""Every rendered page, held to all four markup rules at once."""
|
||||
client = Client(SERVER_NAME=host)
|
||||
client.force_login(user)
|
||||
for route in routes:
|
||||
html = client.get(route).content.decode()
|
||||
with self.subTest(route=route, rule="unquoted multi-token attribute"):
|
||||
self.assertEqual(
|
||||
self.offenders(html), [],
|
||||
f"{route} renders unquoted multi-token attributes",
|
||||
)
|
||||
with self.subTest(route=route, rule="form method"):
|
||||
for value in self.FORM_METHOD.findall(html):
|
||||
self.assertIn(
|
||||
value, ("post", "get"),
|
||||
f"{route} renders a form whose method is {value!r}",
|
||||
)
|
||||
with self.subTest(route=route, rule="folded attribute"):
|
||||
self.assertEqual(
|
||||
self.folded_attributes(html), [],
|
||||
f"{route} folds two attributes into one quoted value",
|
||||
)
|
||||
with self.subTest(route=route, rule="leaked class"):
|
||||
self.assertEqual(
|
||||
self.leaked_classes(html), [],
|
||||
f"{route} renders a class outside its class attribute",
|
||||
)
|
||||
|
||||
def test_ops_pages_render_clean_markup(self):
|
||||
batch = make_sweep_batch()
|
||||
shipment = make_sweep_shipment(batch)
|
||||
self.check_pages(
|
||||
User.objects.create_user(
|
||||
email="attr-ops@ixg.local", password="pass1234", role="ops",
|
||||
full_name="Attr Ops", is_staff=True,
|
||||
),
|
||||
OPS_ROUTES + batch_screens(batch) + shipment_screens(shipment),
|
||||
"ops.ixgltd.com",
|
||||
)
|
||||
|
||||
def test_portal_pages_render_clean_markup(self):
|
||||
self.check_pages(
|
||||
User.objects.create_user(
|
||||
email="attr-buyer@ixg.local", password="pass1234", role="buyer",
|
||||
full_name="Attr Buyer", is_staff=True,
|
||||
),
|
||||
PORTAL_ROUTES, "portal.ixgltd.com",
|
||||
)
|
||||
|
||||
|
||||
class TemplateSourceHygieneTests(SimpleTestCase):
|
||||
"""The same class-leak check, on the template source instead of a response.
|
||||
|
||||
The route sweep above does not render every *detail* page — and the detail
|
||||
pages are exactly where the status badges are. Seven templates kept their
|
||||
badge colour outside the class attribute, which showed up only as a blank
|
||||
Status column in a screenshot.
|
||||
|
||||
Scanning the source is sound for this particular question, and only this one:
|
||||
once `{% %}` tags are stripped, a class list split across `{% if %}` branches
|
||||
is unambiguous. The *unquoted attribute* question stays on rendered pages,
|
||||
because there a template can be ambiguous and a response cannot.
|
||||
"""
|
||||
|
||||
QUOTED = re.compile(r'"[^"]*"|\'[^\']*\'')
|
||||
TEMPLATE_TAG = re.compile(r"\{%[^%]*%\}", re.S)
|
||||
|
||||
def test_no_template_puts_a_class_outside_its_class_attribute(self):
|
||||
root = Path(settings.BASE_DIR) / "templates"
|
||||
offenders = []
|
||||
for path in sorted(root.rglob("*.html")):
|
||||
text = path.read_text()
|
||||
for line, tag in scan_tags(text):
|
||||
stripped = self.QUOTED.sub(" ", self.TEMPLATE_TAG.sub(" ", tag[1:-1]))
|
||||
for token in stripped.split():
|
||||
if TemplateAttributeHygieneTests.is_leaked_class(token):
|
||||
offenders.append(f"{path.relative_to(root)}:{line} {token}")
|
||||
self.assertEqual(
|
||||
offenders, [], "a class list is sitting outside its class attribute"
|
||||
)
|
||||
112
apps/core/tiles.py
Normal file
112
apps/core/tiles.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Map tiles, served from our own origin.
|
||||
|
||||
The origin-zone map is drawn from a stored polygon, but the background imagery
|
||||
comes from a public tile server. That makes the map the only part of the admin
|
||||
page that depends on a *third-party* domain, and it is the part that breaks:
|
||||
a viewer whose network, proxy or browser filter refuses tile.openstreetmap.org
|
||||
(the refusal arrives as a 403) gets a map that never draws, even though the page,
|
||||
the Leaflet bundle and the polygon data are all fine and same-origin.
|
||||
|
||||
So the browser asks us for tiles and we fetch them:
|
||||
|
||||
browser -> /tiles/{z}/{x}/{y}.png -> <upstream tile server>
|
||||
|
||||
Two things this buys. The map stops depending on the viewer's network path to a
|
||||
third party, and tiles are cached on this server, so a page that is opened
|
||||
repeatedly does not re-fetch them.
|
||||
|
||||
The upstream stays configurable (``LEAFLET_TILE_URL``) because OpenStreetMap's
|
||||
tile policy deliberately does not cover heavy commercial use — the same setting
|
||||
points the map at a paid provider or a self-hosted tile server without further
|
||||
code changes.
|
||||
"""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseNotFound
|
||||
|
||||
# How long a browser may reuse a tile. Tiles are effectively static.
|
||||
BROWSER_CACHE_SECONDS = 60 * 60 * 24 * 7
|
||||
|
||||
MAX_ZOOM = 19
|
||||
FETCH_TIMEOUT_SECONDS = 5
|
||||
|
||||
# Identify the application to the upstream, as tile providers require.
|
||||
UPSTREAM_USER_AGENT = (
|
||||
"IXG-Platform/1.0 (internal origin-zone map; contact the platform administrator)"
|
||||
)
|
||||
|
||||
|
||||
def browser_tile_url():
|
||||
"""The tile URL template the *browser* should use.
|
||||
|
||||
With the proxy on (the default) this is a same-origin path; with it off, the
|
||||
browser talks to the upstream directly, as it did before.
|
||||
"""
|
||||
if settings.LEAFLET_TILE_PROXY_ENABLED:
|
||||
return "/tiles/{z}/{x}/{y}.png"
|
||||
return settings.LEAFLET_TILE_URL
|
||||
|
||||
|
||||
def cache_dir():
|
||||
return Path(settings.LEAFLET_TILE_CACHE_DIR)
|
||||
|
||||
|
||||
def upstream_url(z, x, y):
|
||||
"""Format the configured upstream template for one tile."""
|
||||
url = settings.LEAFLET_TILE_URL
|
||||
# Some providers use a {s} subdomain placeholder; any label works.
|
||||
url = url.replace("{s}", "a")
|
||||
return (
|
||||
url.replace("{z}", str(z)).replace("{x}", str(x)).replace("{y}", str(y))
|
||||
)
|
||||
|
||||
|
||||
def valid_tile(z, x, y):
|
||||
"""Reject anything that is not a real tile address for the zoom level."""
|
||||
if not 0 <= z <= MAX_ZOOM:
|
||||
return False
|
||||
span = 2 ** z
|
||||
return 0 <= x < span and 0 <= y < span
|
||||
|
||||
|
||||
def tile_view(request, z, x, y):
|
||||
"""Serve one tile, from cache when possible, otherwise from the upstream."""
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseForbidden("Authentication required")
|
||||
|
||||
if not valid_tile(z, x, y):
|
||||
return HttpResponseNotFound("No such tile")
|
||||
|
||||
cached = cache_dir() / str(z) / str(x) / f"{y}.png"
|
||||
if cached.is_file():
|
||||
return serve(cached.read_bytes())
|
||||
|
||||
try:
|
||||
request_obj = urllib.request.Request(
|
||||
upstream_url(z, x, y), headers={"User-Agent": UPSTREAM_USER_AGENT}
|
||||
)
|
||||
with urllib.request.urlopen(request_obj, timeout=FETCH_TIMEOUT_SECONDS) as resp:
|
||||
data = resp.read()
|
||||
except (urllib.error.URLError, urllib.error.HTTPError, OSError, ValueError):
|
||||
# The upstream refused or is unreachable. Answer with an empty tile rather
|
||||
# than an error page: the map keeps its polygon and its controls, and the
|
||||
# viewer sees a blank background instead of a broken grid.
|
||||
return HttpResponse(status=204)
|
||||
|
||||
try:
|
||||
cached.parent.mkdir(parents=True, exist_ok=True)
|
||||
cached.write_bytes(data)
|
||||
except OSError:
|
||||
# A read-only or full cache directory must not break the map.
|
||||
pass
|
||||
|
||||
return serve(data)
|
||||
|
||||
|
||||
def serve(data):
|
||||
response = HttpResponse(data, content_type="image/png")
|
||||
response["Cache-Control"] = f"public, max-age={BROWSER_CACHE_SECONDS}"
|
||||
return response
|
||||
27
apps/core/views.py
Normal file
27
apps/core/views.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from django.contrib.auth.views import redirect_to_login
|
||||
from django.shortcuts import redirect
|
||||
from django.views import View
|
||||
|
||||
|
||||
class RoleRedirectView(View):
|
||||
"""Send a signed-in user to their own portal; send visitors to sign-in.
|
||||
|
||||
The platform root is shared by all three portals, so it cannot point at one
|
||||
of them. It used to hard-code ``/admin/``, which bounced a buyer off an area
|
||||
they cannot enter; the destination now depends on the role.
|
||||
|
||||
``/`` deliberately sits outside every RBAC route prefix, so
|
||||
``RBACMiddleware`` can redirect a wrong-role request here (via
|
||||
``LOGIN_REDIRECT_URL``) without looping back into a route it would redirect
|
||||
again.
|
||||
"""
|
||||
|
||||
def get(self, request):
|
||||
user = request.user
|
||||
if not user.is_authenticated:
|
||||
return redirect_to_login(request.get_full_path())
|
||||
if user.role == "admin" or user.is_superuser:
|
||||
return redirect("/admin/")
|
||||
if user.role == "ops":
|
||||
return redirect("/ops/")
|
||||
return redirect("/portal/")
|
||||
60
apps/documents/downloads.py
Normal file
60
apps/documents/downloads.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Permission-checked document downloads.
|
||||
|
||||
Neither portal can serve a stored document today, and the reason is structural
|
||||
rather than a missing link. The portals sit behind nginx servers that proxy
|
||||
``location /`` to the Django app **with a path prefix** (``/portal/`` and
|
||||
``/ops/``), so a stored ``/media/...`` URL arrives at Django as
|
||||
``/portal/media/...`` — a route that does not exist. Django's own DEBUG media
|
||||
route is registered at ``/media/``, not under either prefix, so even a file that
|
||||
is present on disk comes back as a page-not-found.
|
||||
|
||||
Serving media directly from the web server would not be the answer either:
|
||||
every document row carries a visibility setting (buyer / ops / admin) and every
|
||||
certificate carries a status, so the file behind a row is not public. The
|
||||
download has to pass through the same permission gate as the page that links to
|
||||
it.
|
||||
|
||||
So: these helpers resolve a stored ``file_url`` to a real path under
|
||||
``MEDIA_ROOT`` — refusing anything that escapes it — and stream the bytes back
|
||||
to a caller that has already passed a portal permission gate.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse, Http404
|
||||
|
||||
|
||||
def resolve_media_path(file_url):
|
||||
"""Absolute path for a stored ``/media/...`` file_url, or None if unacceptable.
|
||||
|
||||
None means "do not serve this": an empty value, a path outside MEDIA_URL, or
|
||||
a path that resolves outside MEDIA_ROOT (``../`` and symlink escapes).
|
||||
"""
|
||||
if not file_url:
|
||||
return None
|
||||
|
||||
media_url = settings.MEDIA_URL.rstrip("/") # "/media"
|
||||
rel = file_url.strip()
|
||||
if rel.startswith(media_url + "/"):
|
||||
rel = rel[len(media_url) + 1:]
|
||||
elif rel.startswith("/"):
|
||||
# An absolute filesystem path, rather than a path under MEDIA_URL.
|
||||
return None
|
||||
|
||||
base = Path(settings.MEDIA_ROOT).resolve()
|
||||
target = (base / rel).resolve()
|
||||
if target != base and base not in target.parents:
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def file_response(file_url, download_name=None):
|
||||
"""Stream a stored file back as an inline response.
|
||||
|
||||
Raises 404 when the value cannot be resolved or the file is not on disk, so a
|
||||
broken row reads as "not found" rather than as a server error.
|
||||
"""
|
||||
path = resolve_media_path(file_url)
|
||||
if path is None or not path.is_file():
|
||||
raise Http404("Document file not found")
|
||||
return FileResponse(open(path, "rb"), filename=download_name or path.name)
|
||||
209
apps/documents/tests.py
Normal file
209
apps/documents/tests.py
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
"""Downloads must be gated the same way the page that links to them is.
|
||||
|
||||
Two classes of failure matter here and neither is caught by "the link renders":
|
||||
a file that cannot be served at all (the state the portals were in), and a file
|
||||
served to someone who should not have it. Both are covered below, plus the path
|
||||
handling that turns a stored value into a real file.
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase, override_settings
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
|
||||
from .downloads import resolve_media_path
|
||||
from .models import CertificateOfAnalysis, Document
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
PDF_BYTES = b"%PDF-1.4\n% synthetic document for tests\n%%EOF\n"
|
||||
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
class DownloadTestCase(TestCase):
|
||||
"""Fixtures: a real temp media root, plus one user per role."""
|
||||
|
||||
def setUp(self):
|
||||
self.media_root = Path(tempfile.mkdtemp(prefix="ixg-download-test-"))
|
||||
self.addCleanup(shutil.rmtree, self.media_root, True)
|
||||
self.settings_override = override_settings(MEDIA_ROOT=self.media_root)
|
||||
self.settings_override.enable()
|
||||
self.addCleanup(self.settings_override.disable)
|
||||
|
||||
self.buyer = make_user("buyer@ixg.local", "buyer")
|
||||
self.ops = make_user("ops@ixg.local", "ops")
|
||||
self.admin = make_user("admin@ixg.local", "admin")
|
||||
|
||||
self.batch = ProcessingBatch.objects.create(
|
||||
batch_reference="IXG-BATCH-TEST-1",
|
||||
grade="A",
|
||||
weight_kg=Decimal("1000.00"),
|
||||
origin_facility="ASI - Katsina State, Nigeria",
|
||||
created_by=self.ops,
|
||||
)
|
||||
|
||||
self.buyer_doc = self.make_document("packing-list.pdf", Document.Visibility.BUYER)
|
||||
self.ops_doc = self.make_document("lab-report.pdf", Document.Visibility.OPS)
|
||||
self.admin_doc = self.make_document("internal-note.pdf", Document.Visibility.ADMIN)
|
||||
self.published_coa = self.make_coa("coa-published.pdf", CertificateOfAnalysis.Status.PUBLISHED)
|
||||
self.pending_coa = self.make_coa("coa-pending.pdf", CertificateOfAnalysis.Status.PENDING_APPROVAL)
|
||||
|
||||
def write_file(self, name):
|
||||
"""Put real bytes on disk under MEDIA_ROOT and return the stored file_url."""
|
||||
rel = f"ixg-documents/{self.batch.batch_reference.lower()}/{name}"
|
||||
target = self.media_root / rel
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(PDF_BYTES)
|
||||
return f"/media/{rel}"
|
||||
|
||||
def make_document(self, name, visibility):
|
||||
return Document.objects.create(
|
||||
batch=self.batch,
|
||||
doc_type=Document.DocType.OTHER,
|
||||
file_url=self.write_file(name),
|
||||
file_name=name,
|
||||
visibility=visibility,
|
||||
uploaded_by=self.ops,
|
||||
)
|
||||
|
||||
def make_coa(self, name, status):
|
||||
return CertificateOfAnalysis.objects.create(
|
||||
batch=self.batch,
|
||||
file_url=self.write_file(name),
|
||||
file_name=name,
|
||||
status=status,
|
||||
uploaded_by=self.ops,
|
||||
)
|
||||
|
||||
def get(self, user, url, host):
|
||||
c = Client(SERVER_NAME=host)
|
||||
c.force_login(user)
|
||||
return c.get(url)
|
||||
|
||||
@staticmethod
|
||||
def body(response):
|
||||
if getattr(response, "streaming", False):
|
||||
return b"".join(response.streaming_content)
|
||||
return response.content
|
||||
|
||||
|
||||
class BuyerDownloadTests(DownloadTestCase):
|
||||
def test_buyer_downloads_a_buyer_visible_document(self):
|
||||
r = self.get(self.buyer, f"/portal/documents/{self.buyer_doc.id}/download/", "portal.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(self.body(r), PDF_BYTES)
|
||||
self.assertIn("packing-list.pdf", r.headers.get("Content-Disposition", ""))
|
||||
|
||||
def test_buyer_cannot_download_an_operations_document(self):
|
||||
r = self.get(self.buyer, f"/portal/documents/{self.ops_doc.id}/download/", "portal.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_buyer_cannot_download_an_admin_document(self):
|
||||
r = self.get(self.buyer, f"/portal/documents/{self.admin_doc.id}/download/", "portal.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_buyer_downloads_a_published_certificate(self):
|
||||
r = self.get(self.buyer, f"/portal/certificates/{self.published_coa.id}/download/", "portal.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(self.body(r), PDF_BYTES)
|
||||
|
||||
def test_buyer_cannot_download_a_certificate_awaiting_release(self):
|
||||
r = self.get(self.buyer, f"/portal/certificates/{self.pending_coa.id}/download/", "portal.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_anonymous_visitor_is_refused(self):
|
||||
c = Client(SERVER_NAME="portal.ixgltd.com")
|
||||
r = c.get(f"/portal/documents/{self.buyer_doc.id}/download/")
|
||||
self.assertIn(r.status_code, (302, 403))
|
||||
|
||||
def test_ops_user_is_refused_on_the_buyer_route(self):
|
||||
r = self.get(self.ops, f"/portal/documents/{self.buyer_doc.id}/download/", "portal.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
|
||||
class OpsDownloadTests(DownloadTestCase):
|
||||
def test_ops_downloads_a_buyer_visible_document(self):
|
||||
r = self.get(self.ops, f"/ops/documents/{self.buyer_doc.id}/download/", "ops.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(self.body(r), PDF_BYTES)
|
||||
|
||||
def test_ops_downloads_an_operations_document(self):
|
||||
r = self.get(self.ops, f"/ops/documents/{self.ops_doc.id}/download/", "ops.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_ops_can_open_a_certificate_awaiting_release(self):
|
||||
"""A reviewer needs the pending certificate most of all."""
|
||||
r = self.get(self.ops, f"/ops/certificates/{self.pending_coa.id}/download/", "ops.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(self.body(r), PDF_BYTES)
|
||||
|
||||
def test_buyer_is_refused_on_the_ops_route(self):
|
||||
r = self.get(self.buyer, f"/ops/documents/{self.ops_doc.id}/download/", "ops.ixgltd.com")
|
||||
self.assertIn(r.status_code, (302, 403))
|
||||
|
||||
|
||||
class MissingAndHostileFileTests(DownloadTestCase):
|
||||
def test_missing_file_is_not_found_rather_than_a_server_error(self):
|
||||
doc = Document.objects.create(
|
||||
batch=self.batch,
|
||||
doc_type=Document.DocType.OTHER,
|
||||
file_url="/media/ixg-documents/nowhere/nothing-here.pdf",
|
||||
file_name="nothing-here.pdf",
|
||||
visibility=Document.Visibility.BUYER,
|
||||
uploaded_by=self.ops,
|
||||
)
|
||||
r = self.get(self.buyer, f"/portal/documents/{doc.id}/download/", "portal.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_empty_file_url_is_not_found(self):
|
||||
doc = Document.objects.create(
|
||||
batch=self.batch,
|
||||
doc_type=Document.DocType.OTHER,
|
||||
file_url="",
|
||||
file_name="empty.pdf",
|
||||
visibility=Document.Visibility.BUYER,
|
||||
uploaded_by=self.ops,
|
||||
)
|
||||
r = self.get(self.buyer, f"/portal/documents/{doc.id}/download/", "portal.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_a_traversal_out_of_media_root_is_refused(self):
|
||||
doc = Document.objects.create(
|
||||
batch=self.batch,
|
||||
doc_type=Document.DocType.OTHER,
|
||||
file_url="/media/../../config/settings.py",
|
||||
file_name="escaped.pdf",
|
||||
visibility=Document.Visibility.BUYER,
|
||||
uploaded_by=self.ops,
|
||||
)
|
||||
r = self.get(self.buyer, f"/portal/documents/{doc.id}/download/", "portal.ixgltd.com")
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
|
||||
class ResolveMediaPathTests(DownloadTestCase):
|
||||
def test_resolves_a_normal_media_url(self):
|
||||
path = resolve_media_path(self.buyer_doc.file_url)
|
||||
self.assertTrue(path and path.is_file())
|
||||
|
||||
def test_refuses_paths_outside_media_url(self):
|
||||
self.assertIsNone(resolve_media_path("/etc/passwd"))
|
||||
self.assertIsNone(resolve_media_path("/app/config/settings.py"))
|
||||
|
||||
def test_refuses_traversal(self):
|
||||
self.assertIsNone(resolve_media_path("/media/../config/settings.py"))
|
||||
|
||||
def test_refuses_empty_values(self):
|
||||
self.assertIsNone(resolve_media_path(""))
|
||||
self.assertIsNone(resolve_media_path(None))
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import os
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from rest_framework import viewsets, permissions, status
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.compliance.permissions import IsOpsOrAdmin
|
||||
|
||||
from .models import Document, CertificateOfAnalysis, RegulatoryCert
|
||||
from .serializers import DocumentSerializer, DocumentUploadSerializer, CertificateOfAnalysisSerializer, RegulatoryCertSerializer
|
||||
|
||||
|
|
@ -12,14 +15,10 @@ from .serializers import DocumentSerializer, DocumentUploadSerializer, Certifica
|
|||
class DocumentViewSet(viewsets.ModelViewSet):
|
||||
queryset = Document.objects.all()
|
||||
serializer_class = DocumentSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = Document.objects.all()
|
||||
if user.role == "buyer":
|
||||
return qs.filter(visibility__in=["buyer"])
|
||||
return qs
|
||||
# Documents and certificates are the evidence a buyer's auditor relies on.
|
||||
# The buyer portal serves the buyer-visible rows through the ORM
|
||||
# (apps/buyer/views.py), so this API needs no buyer access — read or write.
|
||||
permission_classes = [IsOpsOrAdmin]
|
||||
|
||||
@action(detail=False, methods=["post"], parser_classes=[MultiPartParser, FormParser])
|
||||
def upload(self, request):
|
||||
|
|
@ -64,23 +63,16 @@ class DocumentViewSet(viewsets.ModelViewSet):
|
|||
class CertificateOfAnalysisViewSet(viewsets.ModelViewSet):
|
||||
queryset = CertificateOfAnalysis.objects.select_related("batch", "qc_result").all()
|
||||
serializer_class = CertificateOfAnalysisSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
qs = CertificateOfAnalysis.objects.select_related("batch", "qc_result").all()
|
||||
if user.role == "buyer":
|
||||
return qs.filter(status=CertificateOfAnalysis.Status.PUBLISHED)
|
||||
return qs
|
||||
# A certificate is released to buyers through the ORM (apps/buyer/views.py),
|
||||
# not this API, so the API needs no buyer access. Only a published
|
||||
# certificate is buyer-visible at all, and only ops/admin may publish one.
|
||||
permission_classes = [IsOpsOrAdmin]
|
||||
|
||||
@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)
|
||||
|
|
@ -89,11 +81,8 @@ class CertificateOfAnalysisViewSet(viewsets.ModelViewSet):
|
|||
|
||||
@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:
|
||||
|
|
@ -111,4 +100,6 @@ class CertificateOfAnalysisViewSet(viewsets.ModelViewSet):
|
|||
class RegulatoryCertViewSet(viewsets.ModelViewSet):
|
||||
queryset = RegulatoryCert.objects.all()
|
||||
serializer_class = RegulatoryCertSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
# Regulatory certificates are an internal compliance record; the buyer portal
|
||||
# does not read them, so the API is ops/admin only.
|
||||
permission_classes = [IsOpsOrAdmin]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
from rest_framework import viewsets, permissions
|
||||
from rest_framework import viewsets
|
||||
|
||||
from apps.compliance.permissions import IsOpsOrAdmin
|
||||
|
||||
from .models import ESGFieldReport
|
||||
from .serializers import ESGFieldReportSerializer
|
||||
|
||||
|
|
@ -6,4 +9,7 @@ from .serializers import ESGFieldReportSerializer
|
|||
class ESGFieldReportViewSet(viewsets.ModelViewSet):
|
||||
queryset = ESGFieldReport.objects.all()
|
||||
serializer_class = ESGFieldReportSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
# The buyer portal reads ESG reports through the ORM
|
||||
# (apps/buyer/views.py::BatchDetailView), not this API, so the API needs no
|
||||
# buyer read and is closed to one.
|
||||
permission_classes = [IsOpsOrAdmin]
|
||||
|
|
|
|||
0
apps/guides/__init__.py
Normal file
0
apps/guides/__init__.py
Normal file
11
apps/guides/admin.py
Normal file
11
apps/guides/admin.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import GuidePage
|
||||
|
||||
|
||||
@admin.register(GuidePage)
|
||||
class GuidePageAdmin(admin.ModelAdmin):
|
||||
list_display = ["path", "title", "audience", "updated_at", "synced_at"]
|
||||
list_filter = ["audience"]
|
||||
search_fields = ["path", "title"]
|
||||
readonly_fields = ["synced_at"]
|
||||
7
apps/guides/apps.py
Normal file
7
apps/guides/apps.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class GuidesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.guides"
|
||||
verbose_name = "User Guides"
|
||||
0
apps/guides/management/__init__.py
Normal file
0
apps/guides/management/__init__.py
Normal file
0
apps/guides/management/commands/__init__.py
Normal file
0
apps/guides/management/commands/__init__.py
Normal file
75
apps/guides/management/commands/sync_guides.py
Normal file
75
apps/guides/management/commands/sync_guides.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from django.db import connections
|
||||
from django.utils import timezone
|
||||
from django.utils.dateparse import parse_datetime
|
||||
|
||||
from apps.guides.models import GuidePage
|
||||
|
||||
SELECT_PAGES_SQL = (
|
||||
'SELECT path, title, content, "updatedAt" FROM pages '
|
||||
'WHERE path LIKE %s AND "isPublished" = true AND "isPrivate" = false'
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "One-way sync of Wiki.js user-guides pages into GuidePage (read-only on wikijs)."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
created = 0
|
||||
updated = 0
|
||||
stale = []
|
||||
|
||||
existing = {page.path: page for page in GuidePage.objects.all()}
|
||||
known_paths = set()
|
||||
|
||||
with connections["wikijs"].cursor() as cursor:
|
||||
cursor.execute(SELECT_PAGES_SQL, ["user-guides/%"])
|
||||
rows = cursor.fetchall()
|
||||
|
||||
for path, title, content, updated_at_raw in rows:
|
||||
audience = GuidePage.map_audience(path)
|
||||
if audience is None:
|
||||
continue
|
||||
known_paths.add(path)
|
||||
parsed = parse_datetime(updated_at_raw) if updated_at_raw else None
|
||||
updated_at = parsed or timezone.now()
|
||||
|
||||
page = existing.pop(path, None)
|
||||
if page is None:
|
||||
GuidePage.objects.create(
|
||||
path=path,
|
||||
title=title,
|
||||
audience=audience,
|
||||
content=content,
|
||||
updated_at=updated_at,
|
||||
)
|
||||
created += 1
|
||||
continue
|
||||
|
||||
changed = (
|
||||
page.title != title
|
||||
or page.content != content
|
||||
or page.audience != audience
|
||||
or page.updated_at != updated_at
|
||||
)
|
||||
if changed:
|
||||
page.title = title
|
||||
page.content = content
|
||||
page.audience = audience
|
||||
page.updated_at = updated_at
|
||||
page.save(update_fields=["title", "content", "audience", "updated_at", "synced_at"])
|
||||
updated += 1
|
||||
|
||||
removed = 0
|
||||
for path in existing:
|
||||
if path not in known_paths:
|
||||
stale.append(path)
|
||||
if stale:
|
||||
removed = GuidePage.objects.filter(path__in=stale).delete()[0]
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"sync_guides complete: {created} created, {updated} updated, {removed} removed."
|
||||
)
|
||||
)
|
||||
self.stdout.write(f"Total GuidePage rows: {GuidePage.objects.count()}.")
|
||||
112
apps/guides/markdown.py
Normal file
112
apps/guides/markdown.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import posixpath
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import bleach
|
||||
from django.conf import settings
|
||||
from markdown import markdown
|
||||
|
||||
ALLOWED_TAGS = [
|
||||
"p",
|
||||
"br",
|
||||
"strong",
|
||||
"em",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"a",
|
||||
"code",
|
||||
"pre",
|
||||
"blockquote",
|
||||
"hr",
|
||||
"img",
|
||||
"span",
|
||||
"div",
|
||||
]
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
"a": ["href", "title"],
|
||||
"img": ["src", "alt", "title"],
|
||||
"th": ["align"],
|
||||
"td": ["align"],
|
||||
}
|
||||
|
||||
ALLOWED_PROTOCOLS = ["http", "https", "mailto"]
|
||||
|
||||
EXTENSIONS = ["tables", "fenced_code", "sane_lists"]
|
||||
|
||||
WIKI_IMAGE_PREFIXES = ("/buyer-guide/", "/ops-guide/")
|
||||
|
||||
# Links authored in the wiki are absolute: `/en/user-guides/<x>` is a guide that
|
||||
# this help centre mirrors, while any other `/en/<x>` is a wiki-only page.
|
||||
WIKI_GUIDE_PREFIX = "/en/user-guides/"
|
||||
WIKI_PAGE_PREFIX = "/en/"
|
||||
|
||||
_IMAGE_SRC_RE = re.compile(r'(<img\b[^>]*?\bsrc=")([^"]*)(")', re.IGNORECASE)
|
||||
_ANCHOR_HREF_RE = re.compile(r'(<a\b[^>]*?\bhref=")([^"]*)(")', re.IGNORECASE)
|
||||
|
||||
|
||||
def _rewrite_absolute_anchor(prefix, href, suffix, help_root):
|
||||
"""Point an absolute wiki link at somewhere that actually resolves.
|
||||
|
||||
These hrefs cannot be left alone. Every app host serves only its own
|
||||
portal (/portal/, /ops/, /admin/) and has no `/en/` route, so an untouched
|
||||
`/en/...` link is a 404 for whoever clicks it inside the help centre.
|
||||
|
||||
- `/en/user-guides/<x>` is mirrored in-app -> the in-app help path.
|
||||
- any other `/en/<x>` exists only in the wiki -> the wiki public base.
|
||||
"""
|
||||
path = href.split("#", 1)[0].split("?", 1)[0]
|
||||
if path.startswith(WIKI_GUIDE_PREFIX):
|
||||
target = path[len(WIKI_GUIDE_PREFIX):].strip("/")
|
||||
if target:
|
||||
return f'{prefix}{help_root}{target}/{suffix}'
|
||||
return f'{prefix}{help_root}{suffix}'
|
||||
if path.startswith(WIKI_PAGE_PREFIX):
|
||||
return f'{prefix}{settings.WIKI_PUBLIC_BASE}{path}{suffix}'
|
||||
return None
|
||||
|
||||
|
||||
def _rewrite_anchor(match, base_folder, help_root):
|
||||
prefix, href, suffix = match.groups()
|
||||
parsed = urlparse(href)
|
||||
if parsed.scheme or href.startswith("#"):
|
||||
return match.group(0)
|
||||
if href.startswith("/"):
|
||||
rewritten = _rewrite_absolute_anchor(prefix, href, suffix, help_root)
|
||||
return rewritten if rewritten is not None else match.group(0)
|
||||
resolved = posixpath.normpath(posixpath.join(base_folder, href))
|
||||
if resolved.startswith("user-guides/"):
|
||||
suffix_path = resolved[len("user-guides/"):]
|
||||
return f'{prefix}{help_root}{suffix_path}/{suffix}'
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def _rewrite_image(match):
|
||||
prefix, src, suffix = match.groups()
|
||||
if src.startswith(WIKI_IMAGE_PREFIXES):
|
||||
return f'{prefix}{settings.WIKI_PUBLIC_BASE}{src}{suffix}'
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def render_markdown(content, page_path, help_root):
|
||||
html = markdown(content or "", extensions=EXTENSIONS)
|
||||
base_folder = posixpath.dirname(page_path)
|
||||
html = _ANCHOR_HREF_RE.sub(lambda m: _rewrite_anchor(m, base_folder, help_root), html)
|
||||
html = _IMAGE_SRC_RE.sub(_rewrite_image, html)
|
||||
return bleach.clean(
|
||||
html,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
protocols=ALLOWED_PROTOCOLS,
|
||||
)
|
||||
29
apps/guides/migrations/0001_initial.py
Normal file
29
apps/guides/migrations/0001_initial.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Generated by Django 5.2.12 on 2026-08-07 14:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GuidePage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('path', models.CharField(max_length=255, unique=True)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('audience', models.CharField(choices=[('shared', 'Shared'), ('buyer', 'Buyer'), ('ops', 'Operations'), ('admin', 'Administrator')], max_length=20)),
|
||||
('content', models.TextField(blank=True, default='')),
|
||||
('updated_at', models.DateTimeField()),
|
||||
('synced_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['path'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
apps/guides/migrations/__init__.py
Normal file
0
apps/guides/migrations/__init__.py
Normal file
74
apps/guides/models.py
Normal file
74
apps/guides/models.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from django.db import models
|
||||
|
||||
|
||||
class GuidePage(models.Model):
|
||||
class Audience(models.TextChoices):
|
||||
SHARED = "shared", "Shared"
|
||||
BUYER = "buyer", "Buyer"
|
||||
OPS = "ops", "Operations"
|
||||
ADMIN = "admin", "Administrator"
|
||||
|
||||
AUDIENCE_PREFIXES = (
|
||||
("user-guides/index", Audience.SHARED),
|
||||
("user-guides/buyer/", Audience.BUYER),
|
||||
("user-guides/ops/", Audience.OPS),
|
||||
("user-guides/admin/", Audience.ADMIN),
|
||||
)
|
||||
|
||||
path = models.CharField(max_length=255, unique=True)
|
||||
title = models.CharField(max_length=255)
|
||||
audience = models.CharField(max_length=20, choices=Audience.choices)
|
||||
content = models.TextField(blank=True, default="")
|
||||
updated_at = models.DateTimeField()
|
||||
synced_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["path"]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def page_slug(self):
|
||||
return self.path[len("user-guides/"):] if self.path.startswith("user-guides/") else self.path
|
||||
|
||||
def preview(self, max_chars=180):
|
||||
for line in self.content.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith(("#", "-", ">", "|", "*", "![", "```", "<")):
|
||||
continue
|
||||
text = line.replace("**", "").replace("`", "").replace("*", "").strip()
|
||||
if text:
|
||||
return text[:max_chars]
|
||||
return ""
|
||||
|
||||
@classmethod
|
||||
def map_audience(cls, path):
|
||||
for prefix, audience in cls.AUDIENCE_PREFIXES:
|
||||
if path == prefix or path.startswith(prefix):
|
||||
return audience
|
||||
return None
|
||||
|
||||
|
||||
# The help centre is scoped by PORTAL first, then by role. Which guides are
|
||||
# listed depends on which portal you are standing in, not only on who you are:
|
||||
# an admin browsing the buyer portal sees the buyer guides, because it is the
|
||||
# buyer portal. The admin-only guides never surface there.
|
||||
AUDIENCES_BY_PORTAL = {
|
||||
"portal": frozenset({GuidePage.Audience.SHARED, GuidePage.Audience.BUYER}),
|
||||
"ops": frozenset({GuidePage.Audience.SHARED, GuidePage.Audience.OPS}),
|
||||
}
|
||||
|
||||
# Extra audiences unlocked by ROLE, per portal. Admin guides are ops-side
|
||||
# material, so they are only ever added inside the ops portal.
|
||||
ADMIN_EXTRA_AUDIENCES = {
|
||||
"ops": frozenset({GuidePage.Audience.ADMIN}),
|
||||
}
|
||||
|
||||
|
||||
def allowed_audiences(portal, role=None, is_superuser=False):
|
||||
"""Guide audiences visible in `portal` ("portal" or "ops") for this user."""
|
||||
allowed = set(AUDIENCES_BY_PORTAL.get(portal, frozenset()))
|
||||
if is_superuser or role == "admin":
|
||||
allowed |= ADMIN_EXTRA_AUDIENCES.get(portal, frozenset())
|
||||
return allowed
|
||||
327
apps/guides/tests.py
Normal file
327
apps/guides/tests.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from .markdown import render_markdown
|
||||
from .models import GuidePage, allowed_audiences
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def make_user(email, role):
|
||||
return User.objects.create_user(
|
||||
email=email, password="pass1234", role=role,
|
||||
full_name=email.split("@")[0], is_staff=True,
|
||||
)
|
||||
|
||||
|
||||
def make_page(path, content="# Heading\n\nBody text", title=None):
|
||||
return GuidePage.objects.create(
|
||||
path=path,
|
||||
title=title or path.rsplit("/", 1)[-1],
|
||||
audience=GuidePage.map_audience(path),
|
||||
content=content,
|
||||
updated_at=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
class GuideAudienceFilterTests(TestCase):
|
||||
def setUp(self):
|
||||
self.shared = make_page("user-guides/index", "# User Guides\n\nWelcome.", title="Landing Page")
|
||||
self.buyer = make_page("user-guides/buyer/getting-started", title="Getting Started Guide")
|
||||
self.buyer2 = make_page("user-guides/buyer/dashboard", title="Dashboard Guide")
|
||||
self.ops = make_page("user-guides/ops/batches", title="Ops Batches Guide")
|
||||
self.admin = make_page("user-guides/admin/overview", title="Admin Overview Guide")
|
||||
self.buyer_user = make_user("buyer@ixg.local", "buyer")
|
||||
self.ops_user = make_user("ops@ixg.local", "ops")
|
||||
self.admin_user = make_user("admin@ixg.local", "admin")
|
||||
|
||||
def test_buyer_index_shows_shared_and_buyer_only(self):
|
||||
self.client.force_login(self.buyer_user)
|
||||
resp = self.client.get("/portal/help/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
content = resp.content.decode()
|
||||
self.assertContains(resp, self.shared.title)
|
||||
self.assertContains(resp, self.buyer.title)
|
||||
self.assertContains(resp, self.buyer2.title)
|
||||
self.assertNotContains(resp, self.ops.title)
|
||||
self.assertNotContains(resp, self.admin.title)
|
||||
|
||||
def test_ops_index_shows_shared_and_ops_only(self):
|
||||
self.client.force_login(self.ops_user)
|
||||
resp = self.client.get("/ops/help/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
content = resp.content.decode()
|
||||
self.assertContains(resp, self.shared.title)
|
||||
self.assertContains(resp, self.ops.title)
|
||||
self.assertNotContains(resp, self.buyer.title)
|
||||
self.assertNotContains(resp, self.admin.title)
|
||||
|
||||
def test_admin_index_on_portal_lists_buyer_guides_only(self):
|
||||
"""Portal scoping wins over role: the buyer portal shows buyer guides."""
|
||||
self.client.force_login(self.admin_user)
|
||||
resp = self.client.get("/portal/help/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertContains(resp, self.shared.title)
|
||||
self.assertContains(resp, self.buyer.title)
|
||||
self.assertContains(resp, self.buyer2.title)
|
||||
self.assertNotContains(resp, self.ops.title)
|
||||
self.assertNotContains(resp, self.admin.title)
|
||||
|
||||
def test_admin_index_on_ops_lists_ops_and_admin_guides(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
resp = self.client.get("/ops/help/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertContains(resp, self.shared.title)
|
||||
self.assertContains(resp, self.ops.title)
|
||||
self.assertContains(resp, self.admin.title)
|
||||
self.assertNotContains(resp, self.buyer.title)
|
||||
|
||||
def test_superuser_follows_the_same_portal_scoping(self):
|
||||
superuser = make_user("root@ixg.local", "admin")
|
||||
superuser.is_superuser = True
|
||||
superuser.save(update_fields=["is_superuser"])
|
||||
self.client.force_login(superuser)
|
||||
resp = self.client.get("/portal/help/")
|
||||
self.assertNotContains(resp, self.ops.title)
|
||||
resp = self.client.get("/ops/help/")
|
||||
self.assertContains(resp, self.admin.title)
|
||||
|
||||
def test_ops_only_user_is_refused_at_the_portal_gate(self):
|
||||
"""Only buyer/admin roles reach /portal/ at all (BuyerRequiredMixin).
|
||||
|
||||
So portal scoping here matters for exactly two kinds of viewer: real
|
||||
buyers, and admins who can reach both portals.
|
||||
"""
|
||||
self.client.force_login(self.ops_user)
|
||||
self.assertEqual(self.client.get("/portal/help/").status_code, 403)
|
||||
|
||||
def test_portal_audiences_helper(self):
|
||||
"""The scoping rule itself, independent of view wiring."""
|
||||
A = GuidePage.Audience
|
||||
self.assertEqual(
|
||||
allowed_audiences("portal", role="buyer"),
|
||||
{A.SHARED, A.BUYER},
|
||||
)
|
||||
self.assertEqual(
|
||||
allowed_audiences("portal", role="admin"),
|
||||
{A.SHARED, A.BUYER},
|
||||
msg="admin does not gain ops/admin guides inside the buyer portal",
|
||||
)
|
||||
self.assertEqual(
|
||||
allowed_audiences("ops", role="ops"),
|
||||
{A.SHARED, A.OPS},
|
||||
)
|
||||
self.assertEqual(
|
||||
allowed_audiences("ops", role="admin"),
|
||||
{A.SHARED, A.OPS, A.ADMIN},
|
||||
)
|
||||
self.assertEqual(
|
||||
allowed_audiences("ops", role="buyer", is_superuser=True),
|
||||
{A.SHARED, A.OPS, A.ADMIN},
|
||||
msg="superuser is treated like the admin role",
|
||||
)
|
||||
|
||||
def test_admin_cannot_reach_ops_guide_through_portal(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
resp = self.client.get("/portal/help/ops/batches/")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
resp = self.client.get("/portal/help/admin/overview/")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
# ...but the same guides are reachable through the ops help centre
|
||||
self.assertEqual(self.client.get("/ops/help/ops/batches/").status_code, 200)
|
||||
self.assertEqual(self.client.get("/ops/help/admin/overview/").status_code, 200)
|
||||
|
||||
def test_buyer_cannot_access_ops_or_admin_guide(self):
|
||||
self.client.force_login(self.buyer_user)
|
||||
resp = self.client.get("/portal/help/ops/batches/")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
resp = self.client.get("/portal/help/admin/overview/")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_ops_cannot_access_buyer_guide(self):
|
||||
self.client.force_login(self.ops_user)
|
||||
resp = self.client.get("/ops/help/buyer/getting-started/")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
def test_buyer_can_access_buyer_guide(self):
|
||||
self.client.force_login(self.buyer_user)
|
||||
resp = self.client.get("/portal/help/buyer/getting-started/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertContains(resp, "<h1")
|
||||
|
||||
def test_admin_can_access_admin_guide_via_ops(self):
|
||||
self.client.force_login(self.admin_user)
|
||||
resp = self.client.get("/ops/help/admin/overview/")
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
|
||||
def test_unknown_guide_is_404(self):
|
||||
self.client.force_login(self.buyer_user)
|
||||
resp = self.client.get("/portal/help/nope/missing/")
|
||||
self.assertEqual(resp.status_code, 404)
|
||||
|
||||
|
||||
class GuideMarkdownTests(TestCase):
|
||||
def test_script_tags_are_stripped(self):
|
||||
html = render_markdown(
|
||||
"# Title\n\nHello <script>alert('x')</script> world",
|
||||
"user-guides/buyer/test",
|
||||
"/portal/help/",
|
||||
)
|
||||
self.assertNotIn("<script", html)
|
||||
|
||||
def test_tables_and_heading_render(self):
|
||||
html = render_markdown(
|
||||
"| A | B |\n|---|---|\n| 1 | 2 |",
|
||||
"user-guides/buyer/test",
|
||||
"/portal/help/",
|
||||
)
|
||||
self.assertIn("<table", html)
|
||||
self.assertIn("<th", html)
|
||||
|
||||
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
||||
def test_same_folder_link_rewritten_to_help_root(self):
|
||||
html = render_markdown(
|
||||
"[Compliance guide](compliance)",
|
||||
"user-guides/buyer/getting-started",
|
||||
"/portal/help/",
|
||||
)
|
||||
self.assertIn('href="/portal/help/buyer/compliance/"', html)
|
||||
|
||||
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
||||
def test_index_relative_link_rewritten(self):
|
||||
html = render_markdown(
|
||||
"[Getting started](buyer/getting-started)",
|
||||
"user-guides/index",
|
||||
"/ops/help/",
|
||||
)
|
||||
self.assertIn('href="/ops/help/buyer/getting-started/"', html)
|
||||
|
||||
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
||||
def test_wiki_image_path_rewritten_to_public_base(self):
|
||||
html = render_markdown(
|
||||
"",
|
||||
"user-guides/buyer/test",
|
||||
"/portal/help/",
|
||||
)
|
||||
self.assertIn('src="http://wiki.test/buyer-guide/buyer-dashboard.png"', html)
|
||||
|
||||
def test_anchor_external_and_mailto_links_left_alone(self):
|
||||
html = render_markdown(
|
||||
"[Top](#buyer-portal-guides)\n\n[Email](mailto:a@b.c)\n\n"
|
||||
"[Elsewhere](https://example.com/x)",
|
||||
"user-guides/index",
|
||||
"/portal/help/",
|
||||
)
|
||||
self.assertIn('href="#buyer-portal-guides"', html)
|
||||
self.assertIn('href="mailto:a@b.c"', html)
|
||||
self.assertIn('href="https://example.com/x"', html)
|
||||
|
||||
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
||||
def test_absolute_wiki_guide_link_rewritten_to_help_path(self):
|
||||
"""`/en/user-guides/...` is mirrored in-app, so keep the reader in-app.
|
||||
|
||||
This is the link form the wiki itself needs (Wiki.js resolves a relative
|
||||
link against the page's full path, so `walkthrough/signing-in` on the
|
||||
page `user-guides/ops/walkthrough` 404s). Rewriting it here is what lets
|
||||
one page serve both surfaces.
|
||||
"""
|
||||
html = render_markdown(
|
||||
"[Step 3](/en/user-guides/ops/walkthrough/intakes)",
|
||||
"user-guides/ops/walkthrough",
|
||||
"/ops/help/",
|
||||
)
|
||||
self.assertIn('href="/ops/help/ops/walkthrough/intakes/"', html)
|
||||
|
||||
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
||||
def test_absolute_wiki_guide_link_to_an_index_page(self):
|
||||
html = render_markdown(
|
||||
"[Back to the index](/en/user-guides/index)",
|
||||
"user-guides/buyer/getting-started",
|
||||
"/portal/help/",
|
||||
)
|
||||
self.assertIn('href="/portal/help/index/"', html)
|
||||
|
||||
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
|
||||
def test_absolute_wiki_page_link_rewritten_to_the_wiki(self):
|
||||
"""A wiki page with no in-app copy must point at the wiki.
|
||||
|
||||
The app hosts serve no /en/ routes, so leaving this href alone is a 404.
|
||||
"""
|
||||
html = render_markdown(
|
||||
"[Development Plan](/en/home/development/plan)",
|
||||
"user-guides/index",
|
||||
"/ops/help/",
|
||||
)
|
||||
self.assertIn('href="http://wiki.test/en/home/development/plan"', html)
|
||||
|
||||
def test_absolute_app_paths_left_alone(self):
|
||||
html = render_markdown(
|
||||
"[Ops portal](/ops/batches/)", "user-guides/index", "/ops/help/"
|
||||
)
|
||||
self.assertIn('href="/ops/batches/"', html)
|
||||
|
||||
|
||||
class FakeCursor:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def execute(self, sql, params=None):
|
||||
pass
|
||||
|
||||
def fetchall(self):
|
||||
return self.rows
|
||||
|
||||
|
||||
class SyncGuidesCommandTests(TestCase):
|
||||
def test_sync_maps_audience_and_upserts(self):
|
||||
rows = [
|
||||
("user-guides/index", "User Guides", "# landing", "2026-08-07T13:14:15.809Z"),
|
||||
("user-guides/buyer/getting-started", "Getting Started", "# gs", "2026-08-07T13:25:30.479Z"),
|
||||
("user-guides/ops/batches", "Batches", "# b", "2026-08-07T13:51:48.332Z"),
|
||||
("user-guides/admin/overview", "Admin Overview", "# a", "2026-08-07T13:52:15.763Z"),
|
||||
]
|
||||
with mock.patch(
|
||||
"apps.guides.management.commands.sync_guides.connections"
|
||||
) as mock_connections:
|
||||
mock_connections["wikijs"].cursor.return_value = FakeCursor(rows)
|
||||
call_command("sync_guides")
|
||||
|
||||
self.assertEqual(GuidePage.objects.count(), 4)
|
||||
self.assertEqual(
|
||||
GuidePage.objects.get(path="user-guides/index").audience, GuidePage.Audience.SHARED
|
||||
)
|
||||
self.assertEqual(
|
||||
GuidePage.objects.get(path="user-guides/buyer/getting-started").audience,
|
||||
GuidePage.Audience.BUYER,
|
||||
)
|
||||
self.assertEqual(
|
||||
GuidePage.objects.get(path="user-guides/ops/batches").audience, GuidePage.Audience.OPS
|
||||
)
|
||||
self.assertEqual(
|
||||
GuidePage.objects.get(path="user-guides/admin/overview").audience, GuidePage.Audience.ADMIN
|
||||
)
|
||||
|
||||
def test_sync_updates_changed_and_removes_stale(self):
|
||||
make_page("user-guides/buyer/old", content="old content")
|
||||
rows = [
|
||||
("user-guides/buyer/getting-started", "Getting Started", "# fresh", "2026-08-07T13:25:30.479Z"),
|
||||
]
|
||||
with mock.patch(
|
||||
"apps.guides.management.commands.sync_guides.connections"
|
||||
) as mock_connections:
|
||||
mock_connections["wikijs"].cursor.return_value = FakeCursor(rows)
|
||||
call_command("sync_guides")
|
||||
|
||||
self.assertFalse(GuidePage.objects.filter(path="user-guides/buyer/old").exists())
|
||||
page = GuidePage.objects.get(path="user-guides/buyer/getting-started")
|
||||
self.assertEqual(page.content, "# fresh")
|
||||
66
apps/guides/views.py
Normal file
66
apps/guides/views.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.generic import DetailView, ListView
|
||||
|
||||
from .markdown import render_markdown
|
||||
from .models import GuidePage, allowed_audiences
|
||||
|
||||
|
||||
class GuideAccessMixin:
|
||||
"""Scope the help centre by PORTAL first, then by role.
|
||||
|
||||
Being an admin does not mean seeing every guide in every portal: the buyer
|
||||
portal lists buyer guides (the audience of its readers), the ops portal
|
||||
lists ops guides, and the admin guides only appear in the ops portal for
|
||||
admin/superuser accounts. See apps.guides.models.allowed_audiences.
|
||||
"""
|
||||
|
||||
def get_portal(self):
|
||||
return getattr(self.request.resolver_match, "namespace", None) or "portal"
|
||||
|
||||
def get_allowed_audiences(self):
|
||||
user = self.request.user
|
||||
return allowed_audiences(
|
||||
self.get_portal(),
|
||||
role=getattr(user, "role", None),
|
||||
is_superuser=user.is_superuser,
|
||||
)
|
||||
|
||||
def get_help_root(self):
|
||||
return f"/{self.get_portal()}/help/"
|
||||
|
||||
|
||||
class GuideIndexView(LoginRequiredMixin, GuideAccessMixin, ListView):
|
||||
model = GuidePage
|
||||
context_object_name = "guides"
|
||||
|
||||
def get_queryset(self):
|
||||
return GuidePage.objects.filter(audience__in=self.get_allowed_audiences())
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["help_root"] = self.get_help_root()
|
||||
return ctx
|
||||
|
||||
|
||||
class GuideDetailView(LoginRequiredMixin, GuideAccessMixin, DetailView):
|
||||
model = GuidePage
|
||||
# the templates read {{ guide.title }}; without this the object arrives as
|
||||
# "object" and every guide detail page renders a blank browser title
|
||||
context_object_name = "guide"
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
path = f"user-guides/{self.kwargs['page_path']}"
|
||||
page = get_object_or_404(GuidePage, path=path)
|
||||
if page.audience not in self.get_allowed_audiences():
|
||||
raise Http404("This guide is not available in this portal.")
|
||||
return page
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
help_root = self.get_help_root()
|
||||
ctx["help_root"] = help_root
|
||||
ctx["guide_html"] = render_markdown(self.object.content, self.object.path, help_root)
|
||||
ctx["guides"] = GuidePage.objects.filter(audience__in=self.get_allowed_audiences())
|
||||
return ctx
|
||||
1136
apps/ops/forms.py
Normal file
1136
apps/ops/forms.py
Normal file
File diff suppressed because it is too large
Load diff
50
apps/ops/static/js/ixg_intake_form.js
Normal file
50
apps/ops/static/js/ixg_intake_form.js
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/* Fill an intake's origin zone in from the collector group that delivered it.
|
||||
*
|
||||
* A group is registered to a zone, so choosing the group is enough to know where
|
||||
* the material came from; asking for both invites the two records to disagree.
|
||||
* The zone stays editable, and a saved value is never overwritten — a group can
|
||||
* legitimately deliver from more than one area, and a correction here must stick.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function init() {
|
||||
var form = document.getElementById("intake-form");
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
var groupSelect = document.getElementById("id_collector_group");
|
||||
var zoneSelect = document.getElementById("id_origin_zone");
|
||||
if (!groupSelect || !zoneSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
var groupZones = {};
|
||||
try {
|
||||
groupZones = JSON.parse(form.dataset.groupZones || "{}");
|
||||
} catch (err) {
|
||||
groupZones = {};
|
||||
}
|
||||
|
||||
function fillZone() {
|
||||
var zoneId = groupZones[groupSelect.value];
|
||||
if (zoneId) {
|
||||
zoneSelect.value = zoneId;
|
||||
}
|
||||
}
|
||||
|
||||
// Only fill a blank zone on load, so correcting a record does not silently
|
||||
// rewrite the zone that was recorded at the time.
|
||||
if (!zoneSelect.value) {
|
||||
fillZone();
|
||||
}
|
||||
groupSelect.addEventListener("change", fillZone);
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
1476
apps/ops/tests.py
Normal file
1476
apps/ops/tests.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,14 +6,36 @@ app_name = 'ops'
|
|||
urlpatterns = [
|
||||
path('', views.DashboardView.as_view(), name='dashboard'),
|
||||
path('batches/', views.BatchListView.as_view(), name='batch_list'),
|
||||
path('batches/new/', views.BatchCreateView.as_view(), name='batch_create'),
|
||||
path('batches/<uuid:pk>/', views.BatchDetailView.as_view(), name='batch_detail'),
|
||||
path('batches/<uuid:pk>/edit/', views.BatchUpdateView.as_view(), name='batch_update'),
|
||||
path('batches/<uuid:pk>/lineage/', views.BatchLineageView.as_view(), name='batch_lineage'),
|
||||
path('intakes/', views.IntakeListView.as_view(), name='intake_list'),
|
||||
path('intakes/new/', views.IntakeCreateView.as_view(), name='intake_create'),
|
||||
path('intakes/<uuid:pk>/edit/', views.IntakeUpdateView.as_view(), name='intake_update'),
|
||||
path('facilities/', views.FacilityListView.as_view(), name='facility_list'),
|
||||
path('facilities/new/', views.FacilityCreateView.as_view(), name='facility_create'),
|
||||
path('facilities/<uuid:pk>/edit/', views.FacilityUpdateView.as_view(), name='facility_update'),
|
||||
path('origin-zones/', views.OriginZoneListView.as_view(), name='origin_zone_list'),
|
||||
path('origin-zones/new/', views.OriginZoneCreateView.as_view(), name='origin_zone_create'),
|
||||
path('origin-zones/<uuid:pk>/edit/', views.OriginZoneUpdateView.as_view(), name='origin_zone_update'),
|
||||
path('collector-groups/', views.CollectorGroupListView.as_view(), name='collector_group_list'),
|
||||
path('collector-groups/new/', views.CollectorGroupCreateView.as_view(), name='collector_group_create'),
|
||||
path('collector-groups/<uuid:pk>/edit/', views.CollectorGroupUpdateView.as_view(), name='collector_group_update'),
|
||||
path('specifications/', views.SpecificationListView.as_view(), name='specification_list'),
|
||||
path('specifications/new/', views.SpecificationCreateView.as_view(), name='specification_create'),
|
||||
path('specifications/<uuid:pk>/edit/', views.SpecificationUpdateView.as_view(), name='specification_update'),
|
||||
path('qc/', views.QCListView.as_view(), name='qc_list'),
|
||||
path('qc/<uuid:pk>/', views.QCDetailView.as_view(), name='qc_detail'),
|
||||
path('shipments/', views.ShipmentListView.as_view(), name='shipment_list'),
|
||||
path('shipments/new/', views.ShipmentCreateView.as_view(), name='shipment_new'),
|
||||
path('shipments/<uuid:pk>/', views.ShipmentDetailView.as_view(), name='shipment_detail'),
|
||||
path('shipments/<uuid:pk>/edit/', views.ShipmentUpdateView.as_view(), name='shipment_update'),
|
||||
path('capa/', views.CAPAListView.as_view(), name='capa_list'),
|
||||
path('capa/<uuid:pk>/', views.CAPADetailView.as_view(), name='capa_detail'),
|
||||
path('documents/', views.DocumentListView.as_view(), name='document_list'),
|
||||
path('documents/<uuid:pk>/download/', views.DocumentDownloadView.as_view(), name='document_download'),
|
||||
path('certificates/<uuid:pk>/download/', views.CoADownloadView.as_view(), name='coa_download'),
|
||||
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'),
|
||||
|
|
@ -21,4 +43,11 @@ urlpatterns = [
|
|||
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'),
|
||||
path('eudr/<uuid:case_pk>/evidence/new/', views.EUDREvidenceCreateView.as_view(), name='eudr_evidence_create'),
|
||||
path('eudr/<uuid:case_pk>/evidence/<uuid:pk>/edit/', views.EUDREvidenceUpdateView.as_view(), name='eudr_evidence_update'),
|
||||
path('eudr/<uuid:case_pk>/mitigation/new/', views.MitigationActionCreateView.as_view(), name='eudr_mitigation_create'),
|
||||
path('eudr/<uuid:case_pk>/mitigation/<uuid:pk>/edit/', views.MitigationActionUpdateView.as_view(), name='eudr_mitigation_update'),
|
||||
path('eudr/<uuid:case_pk>/statement/<uuid:pk>/edit/', views.StatementReferenceUpdateView.as_view(), name='eudr_statement_update'),
|
||||
path('help/', views.GuideIndexView.as_view(), name='guide_index'),
|
||||
path('help/<path:page_path>/', views.GuideDetailView.as_view(), name='guide_detail'),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,18 +1,89 @@
|
|||
import json
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.db.models import Count, Q
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView, ListView, DetailView
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.views.generic import TemplateView, ListView, DetailView, CreateView, UpdateView
|
||||
from django.views import View
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
from apps.core.tiles import browser_tile_url
|
||||
from apps.qc.models import QCResult
|
||||
from apps.shipments.models import Shipment
|
||||
from apps.capa.models import CAPARecord
|
||||
from apps.documents.downloads import file_response
|
||||
from apps.documents.models import Document, CertificateOfAnalysis
|
||||
from apps.esg.models import ESGFieldReport
|
||||
from apps.notifications.models import Notification
|
||||
from apps.compliance.models import DueDiligenceCase
|
||||
from apps.compliance.models import (
|
||||
CollectorGroup,
|
||||
DueDiligenceCase,
|
||||
DueDiligenceStatementReference,
|
||||
EUDREvidence,
|
||||
Facility,
|
||||
HarvestIntake,
|
||||
IntakeBatchLineage,
|
||||
MitigationAction,
|
||||
OriginZone,
|
||||
ProductSpecification,
|
||||
)
|
||||
from apps.compliance import services
|
||||
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
||||
from apps.guides.views import GuideIndexView as BaseGuideIndexView
|
||||
from .forms import (
|
||||
BatchForm,
|
||||
BatchLineageFormSet,
|
||||
CollectorGroupForm,
|
||||
EUDREvidenceForm,
|
||||
FacilityForm,
|
||||
HarvestIntakeForm,
|
||||
MitigationActionForm,
|
||||
OriginZoneForm,
|
||||
ShipmentForm,
|
||||
SpecificationForm,
|
||||
SpecParameterFormSet,
|
||||
StatementReferenceForm,
|
||||
)
|
||||
|
||||
|
||||
def suggested_intake_reference(today=None):
|
||||
"""Next intake reference, continuing this year's sequence.
|
||||
|
||||
Suggested rather than imposed: the reference may have to match a weighbridge
|
||||
ticket, so the field stays editable.
|
||||
"""
|
||||
year = (today or timezone.localdate()).year
|
||||
prefix = f"IXG-INTAKE-{year}-"
|
||||
highest = 0
|
||||
for ref in HarvestIntake.objects.filter(
|
||||
intake_reference__startswith=prefix
|
||||
).values_list("intake_reference", flat=True):
|
||||
tail = ref[len(prefix):]
|
||||
if tail.isdigit():
|
||||
highest = max(highest, int(tail))
|
||||
return f"{prefix}{highest + 1:04d}"
|
||||
|
||||
|
||||
def suggested_batch_reference(today=None):
|
||||
"""Next batch reference, continuing this year's sequence. Same reasoning.
|
||||
|
||||
Three digits, because that is the shape the existing references have
|
||||
(``IXG-BATCH-2026-014``); it widens by itself past 999.
|
||||
"""
|
||||
year = (today or timezone.localdate()).year
|
||||
prefix = f"IXG-BATCH-{year}-"
|
||||
highest = 0
|
||||
for ref in ProcessingBatch.objects.filter(
|
||||
batch_reference__startswith=prefix
|
||||
).values_list("batch_reference", flat=True):
|
||||
tail = ref[len(prefix):]
|
||||
if tail.isdigit():
|
||||
highest = max(highest, int(tail))
|
||||
return f"{prefix}{highest + 1:03d}"
|
||||
|
||||
|
||||
class OpsRequiredMixin(UserPassesTestMixin):
|
||||
|
|
@ -87,9 +158,150 @@ class BatchDetailView(OpsRequiredMixin, DetailView):
|
|||
ctx['capa_records'] = CAPARecord.objects.filter(batch=batch)
|
||||
ctx['documents'] = Document.objects.filter(batch=batch)
|
||||
ctx['esg_reports'] = ESGFieldReport.objects.filter(batch=batch)
|
||||
lineage = batch.intake_lineage.select_related(
|
||||
'harvest_intake',
|
||||
'harvest_intake__origin_zone',
|
||||
'harvest_intake__collector_group',
|
||||
).order_by('harvest_intake__intake_reference')
|
||||
ctx['lineage'] = lineage
|
||||
ctx['lineage_allocated'] = sum((row.weight_allocated_kg or 0) for row in lineage)
|
||||
if batch.weight_kg is not None:
|
||||
ctx['lineage_difference'] = batch.weight_kg - ctx['lineage_allocated']
|
||||
return ctx
|
||||
|
||||
|
||||
class BatchCreateView(OpsRequiredMixin, CreateView):
|
||||
model = ProcessingBatch
|
||||
form_class = BatchForm
|
||||
template_name = 'ops/batch_form.html'
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
initial['batch_reference'] = suggested_batch_reference()
|
||||
initial['harvest_date'] = timezone.localdate()
|
||||
initial['status'] = ProcessingBatch.Status.HARVESTED
|
||||
return initial
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New processing batch'
|
||||
ctx['submit_label'] = 'Create batch'
|
||||
# The template cannot ask the instance whether it is new — ProcessingBatch's
|
||||
# primary key is a UUID with a default, so an unsaved batch already has one,
|
||||
# and `_state` is unreachable from a template. The view knows, so it says.
|
||||
ctx['creating'] = True
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.created_by = self.request.user
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Batch {self.object.batch_reference} created. "
|
||||
"Allocate its harvest intakes next — a batch with no lineage fails "
|
||||
"the due-diligence lineage check.",
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
# Straight to the lineage, because a batch with no intake behind it is
|
||||
# not traceable and the check will say so.
|
||||
return reverse('ops:batch_lineage', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class BatchUpdateView(OpsRequiredMixin, UpdateView):
|
||||
model = ProcessingBatch
|
||||
form_class = BatchForm
|
||||
template_name = 'ops/batch_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = f"Edit batch {self.object.batch_reference}"
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
ctx['creating'] = False
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request, f"Batch {self.object.batch_reference} updated."
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:batch_detail', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class BatchLineageView(OpsRequiredMixin, View):
|
||||
"""Allocate harvest intakes to a batch, and correct what is already there.
|
||||
|
||||
This is the screen that closes the lineage gap in walkthrough steps 3 and 4:
|
||||
the link between an intake and a batch, with the weight that went into it, is
|
||||
created here rather than in the administration area. The formset refuses an
|
||||
allocation the intake cannot support — see
|
||||
``apps/ops/forms.py::BaseBatchLineageFormSet``.
|
||||
"""
|
||||
|
||||
template_name = 'ops/batch_lineage.html'
|
||||
|
||||
def get_batch(self):
|
||||
return get_object_or_404(ProcessingBatch, pk=self.kwargs['pk'])
|
||||
|
||||
def get_formset(self, batch, data=None):
|
||||
queryset = IntakeBatchLineage.objects.filter(batch=batch).select_related(
|
||||
'harvest_intake'
|
||||
).order_by('harvest_intake__intake_reference')
|
||||
return BatchLineageFormSet(
|
||||
data,
|
||||
queryset=queryset,
|
||||
prefix='lineage',
|
||||
batch=batch,
|
||||
form_kwargs={'batch': batch},
|
||||
)
|
||||
|
||||
def get_context_data(self, batch, formset):
|
||||
rows = list(batch.intake_lineage.select_related(
|
||||
'harvest_intake', 'harvest_intake__origin_zone',
|
||||
'harvest_intake__collector_group',
|
||||
).order_by('harvest_intake__intake_reference'))
|
||||
allocated = sum((row.weight_allocated_kg or 0) for row in rows)
|
||||
return {
|
||||
'batch': batch,
|
||||
'formset': formset,
|
||||
'lineage_rows': rows,
|
||||
'total_allocated': allocated,
|
||||
'difference': (batch.weight_kg - allocated)
|
||||
if batch.weight_kg is not None else None,
|
||||
}
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
batch = self.get_batch()
|
||||
formset = self.get_formset(batch)
|
||||
return render(
|
||||
request, self.template_name, self.get_context_data(batch, formset)
|
||||
)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
batch = self.get_batch()
|
||||
formset = self.get_formset(batch, data=request.POST)
|
||||
if formset.is_valid():
|
||||
# A plain ModelFormSet does not know the batch, so the new rows get it
|
||||
# here; the deleted ones are removed explicitly.
|
||||
for obj in formset.save(commit=False):
|
||||
obj.batch = batch
|
||||
obj.save()
|
||||
for obj in formset.deleted_objects:
|
||||
obj.delete()
|
||||
messages.success(
|
||||
request,
|
||||
f"Intake lineage for {batch.batch_reference} updated.",
|
||||
)
|
||||
return redirect('ops:batch_detail', pk=batch.pk)
|
||||
return render(
|
||||
request, self.template_name, self.get_context_data(batch, formset)
|
||||
)
|
||||
|
||||
|
||||
class QCListView(OpsRequiredMixin, ListView):
|
||||
model = QCResult
|
||||
template_name = 'ops/qc_list.html'
|
||||
|
|
@ -143,6 +355,92 @@ class ShipmentDetailView(OpsRequiredMixin, DetailView):
|
|||
context_object_name = 'shipment'
|
||||
|
||||
|
||||
class ShipmentFormContextMixin:
|
||||
"""Shared context for the shipment create and edit forms.
|
||||
|
||||
The batch's own status is shown on the form through the batch dropdown's
|
||||
label, and the other shipments on the batch are listed here so an operator
|
||||
can see a possible duplicate before saving — the form does not block one,
|
||||
because a split consignment is legitimate.
|
||||
"""
|
||||
|
||||
template_name = 'ops/shipment_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
shipment = getattr(self, 'object', None)
|
||||
if shipment is not None and shipment.pk:
|
||||
ctx['other_shipments'] = shipment.batch.shipments.exclude(pk=shipment.pk)
|
||||
else:
|
||||
ctx['other_shipments'] = Shipment.objects.none()
|
||||
return ctx
|
||||
|
||||
|
||||
class ShipmentCreateView(ShipmentFormContextMixin, OpsRequiredMixin, CreateView):
|
||||
model = Shipment
|
||||
form_class = ShipmentForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New shipment'
|
||||
ctx['submit_label'] = 'Create shipment'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
batch = form.instance.batch
|
||||
existing = Shipment.objects.filter(batch=batch).count()
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request, f"Shipment for {batch.batch_reference} created."
|
||||
)
|
||||
if existing:
|
||||
messages.warning(
|
||||
self.request,
|
||||
f"{batch.batch_reference} already had {existing} shipment(s). This "
|
||||
"was saved as a further consignment — check it is not a duplicate.",
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:shipment_detail', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class ShipmentUpdateView(ShipmentFormContextMixin, OpsRequiredMixin, UpdateView):
|
||||
model = Shipment
|
||||
form_class = ShipmentForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Edit shipment for {self.object.batch.batch_reference}"
|
||||
)
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
batch = form.instance.batch
|
||||
previous_batch_id = (
|
||||
Shipment.objects.filter(pk=self.object.pk)
|
||||
.values_list('batch_id', flat=True)
|
||||
.first()
|
||||
)
|
||||
others = Shipment.objects.filter(batch=batch).exclude(pk=self.object.pk).count()
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request, f"Shipment for {batch.batch_reference} updated."
|
||||
)
|
||||
if others and previous_batch_id != batch.pk:
|
||||
messages.warning(
|
||||
self.request,
|
||||
f"{batch.batch_reference} already had {others} shipment(s). This "
|
||||
"was saved as a further consignment — check it is not a duplicate.",
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:shipment_detail', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class CAPAListView(OpsRequiredMixin, ListView):
|
||||
model = CAPARecord
|
||||
template_name = 'ops/capa_list.html'
|
||||
|
|
@ -189,6 +487,32 @@ class DocumentListView(OpsRequiredMixin, ListView):
|
|||
return ctx
|
||||
|
||||
|
||||
class DocumentDownloadView(OpsRequiredMixin, View):
|
||||
"""Serve the stored file behind a document row to operations staff.
|
||||
|
||||
Operations may fetch any document the ops document list shows it. Note the
|
||||
list itself has always shown every row regardless of visibility, so this
|
||||
matches the list rather than being stricter than it; the buyer-side view is
|
||||
where visibility does the gatekeeping.
|
||||
"""
|
||||
|
||||
def get(self, request, pk):
|
||||
doc = get_object_or_404(Document, pk=pk)
|
||||
return file_response(doc.file_url, doc.file_name)
|
||||
|
||||
|
||||
class CoADownloadView(OpsRequiredMixin, View):
|
||||
"""Serve a certificate of analysis to operations staff, at any status.
|
||||
|
||||
Unlike the buyer's view, a certificate still awaiting release is exactly what
|
||||
a reviewer needs to open, so the status is not part of the gate here.
|
||||
"""
|
||||
|
||||
def get(self, request, pk):
|
||||
coa = get_object_or_404(CertificateOfAnalysis, pk=pk)
|
||||
return file_response(coa.file_url, coa.file_name)
|
||||
|
||||
|
||||
class ESGListView(OpsRequiredMixin, ListView):
|
||||
model = ESGFieldReport
|
||||
template_name = 'ops/esg_list.html'
|
||||
|
|
@ -342,7 +666,6 @@ class EUDRCaseDetailView(OpsRequiredMixin, DetailView):
|
|||
return ctx
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
from django.contrib import messages
|
||||
from apps.compliance import services
|
||||
|
||||
case = self.get_object()
|
||||
|
|
@ -370,3 +693,600 @@ class EUDRCaseDetailView(OpsRequiredMixin, DetailView):
|
|||
except ValueError as exc:
|
||||
messages.error(request, str(exc))
|
||||
return redirect(self.request.path)
|
||||
|
||||
|
||||
class IntakeListView(OpsRequiredMixin, ListView):
|
||||
"""Harvest intakes, the first record the business actually creates itself."""
|
||||
|
||||
model = HarvestIntake
|
||||
template_name = 'ops/intake_list.html'
|
||||
context_object_name = 'intakes'
|
||||
paginate_by = 25
|
||||
|
||||
def get_queryset(self):
|
||||
qs = HarvestIntake.objects.select_related(
|
||||
'collector_group', 'origin_zone'
|
||||
).order_by('-intake_date', '-intake_reference')
|
||||
status = self.request.GET.get('status')
|
||||
q = self.request.GET.get('q')
|
||||
if status:
|
||||
qs = qs.filter(status=status)
|
||||
if q:
|
||||
qs = qs.filter(intake_reference__icontains=q)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['status_choices'] = HarvestIntake.Status.choices
|
||||
ctx['current_status'] = self.request.GET.get('status', '')
|
||||
ctx['current_q'] = self.request.GET.get('q', '')
|
||||
return ctx
|
||||
|
||||
|
||||
class IntakeFormContextMixin:
|
||||
"""Shared context for the create and edit forms."""
|
||||
|
||||
template_name = 'ops/intake_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
# Which zone each collector group draws from, so the form can fill the
|
||||
# zone in when a group is chosen. Kept in a data attribute rather than
|
||||
# injected into a script block.
|
||||
ctx['group_zones'] = json.dumps({
|
||||
str(group.pk): str(group.origin_zone_id)
|
||||
for group in CollectorGroup.objects.exclude(origin_zone__isnull=True)
|
||||
})
|
||||
return ctx
|
||||
|
||||
|
||||
class IntakeCreateView(IntakeFormContextMixin, OpsRequiredMixin, CreateView):
|
||||
model = HarvestIntake
|
||||
form_class = HarvestIntakeForm
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
initial['intake_reference'] = suggested_intake_reference()
|
||||
initial['intake_date'] = timezone.localdate()
|
||||
initial['status'] = HarvestIntake.Status.RECEIVED
|
||||
officer = self.request.user.full_name or self.request.user.email
|
||||
if officer:
|
||||
initial['receiving_officer'] = officer
|
||||
return initial
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New harvest intake'
|
||||
ctx['submit_label'] = 'Record intake'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request, f"Intake {self.object.intake_reference} recorded."
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:intake_list')
|
||||
|
||||
|
||||
class IntakeUpdateView(IntakeFormContextMixin, OpsRequiredMixin, UpdateView):
|
||||
model = HarvestIntake
|
||||
form_class = HarvestIntakeForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = f"Correct intake {self.object.intake_reference}"
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request, f"Intake {self.object.intake_reference} updated."
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:intake_list')
|
||||
|
||||
|
||||
class GuideIndexView(OpsRequiredMixin, BaseGuideIndexView):
|
||||
template_name = 'guides/index_ops.html'
|
||||
|
||||
|
||||
class GuideDetailView(OpsRequiredMixin, BaseGuideDetailView):
|
||||
template_name = 'guides/detail_ops.html'
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Compliance registers
|
||||
#
|
||||
# Facility, origin zone and collector group used to be editable only in the
|
||||
# Django administration area, which left the platform unable to register a new
|
||||
# supply site, zone or group from the portal. These screens close that gap. The
|
||||
# admin registrations stay: the portal and the admin are two write paths to the
|
||||
# same records, and the forms here mirror the admin's fields.
|
||||
|
||||
|
||||
class FacilityListView(OpsRequiredMixin, ListView):
|
||||
model = Facility
|
||||
template_name = 'ops/facility_list.html'
|
||||
context_object_name = 'facilities'
|
||||
paginate_by = 25
|
||||
|
||||
def get_queryset(self):
|
||||
qs = Facility.objects.all().order_by('name')
|
||||
q = self.request.GET.get('q')
|
||||
active = self.request.GET.get('active')
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
if active == '1':
|
||||
qs = qs.filter(is_active=True)
|
||||
elif active == '0':
|
||||
qs = qs.filter(is_active=False)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['current_q'] = self.request.GET.get('q', '')
|
||||
ctx['current_active'] = self.request.GET.get('active', '')
|
||||
return ctx
|
||||
|
||||
|
||||
class FacilityFormContextMixin:
|
||||
model = Facility
|
||||
form_class = FacilityForm
|
||||
template_name = 'ops/register_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['cancel_url'] = reverse('ops:facility_list')
|
||||
return ctx
|
||||
|
||||
|
||||
class FacilityCreateView(FacilityFormContextMixin, OpsRequiredMixin, CreateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New facility'
|
||||
ctx['submit_label'] = 'Create facility'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Facility {self.object.name} created.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:facility_list')
|
||||
|
||||
|
||||
class FacilityUpdateView(FacilityFormContextMixin, OpsRequiredMixin, UpdateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = f"Edit facility {self.object.name}"
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Facility {self.object.name} updated.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:facility_list')
|
||||
|
||||
|
||||
def apply_zone_verification(zone, user, previous_status=None):
|
||||
"""Record who verified or rejected a zone, and when.
|
||||
|
||||
A zone moved back to Unverified has its stamp cleared, so the record and the
|
||||
audit agree. An unchanged status keeps the original verifier and date — a
|
||||
later edit of an unrelated field must not silently re-attribute the check.
|
||||
"""
|
||||
if zone.verification_status == OriginZone.VerificationStatus.UNVERIFIED:
|
||||
zone.verified_by = None
|
||||
zone.verified_at = None
|
||||
elif previous_status != zone.verification_status or zone.verified_at is None:
|
||||
zone.verified_by = user
|
||||
zone.verified_at = timezone.now()
|
||||
|
||||
|
||||
class OriginZoneListView(OpsRequiredMixin, ListView):
|
||||
model = OriginZone
|
||||
template_name = 'ops/origin_zone_list.html'
|
||||
context_object_name = 'zones'
|
||||
paginate_by = 25
|
||||
|
||||
def get_queryset(self):
|
||||
qs = OriginZone.objects.select_related('facility').order_by('zone_code')
|
||||
q = self.request.GET.get('q')
|
||||
status = self.request.GET.get('status')
|
||||
if q:
|
||||
qs = qs.filter(zone_code__icontains=q)
|
||||
if status:
|
||||
qs = qs.filter(verification_status=status)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['status_choices'] = OriginZone.VerificationStatus.choices
|
||||
ctx['current_q'] = self.request.GET.get('q', '')
|
||||
ctx['current_status'] = self.request.GET.get('status', '')
|
||||
return ctx
|
||||
|
||||
|
||||
class OriginZoneFormContextMixin:
|
||||
model = OriginZone
|
||||
form_class = OriginZoneForm
|
||||
template_name = 'ops/register_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['cancel_url'] = reverse('ops:origin_zone_list')
|
||||
ctx['show_zone_map'] = True
|
||||
ctx['tile_url'] = browser_tile_url()
|
||||
zone = getattr(self, 'object', None)
|
||||
geometry = getattr(zone, 'geometry', None) if zone is not None else None
|
||||
ctx['zone_has_geometry'] = bool(geometry and geometry.get('coordinates'))
|
||||
ctx['zone_geometry_json'] = json.dumps(geometry) if ctx['zone_has_geometry'] else ''
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
apply_zone_verification(
|
||||
form.instance,
|
||||
self.request.user,
|
||||
previous_status=form.initial.get('verification_status'),
|
||||
)
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
class OriginZoneCreateView(OriginZoneFormContextMixin, OpsRequiredMixin, CreateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New origin zone'
|
||||
ctx['submit_label'] = 'Create zone'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Origin zone {self.object.zone_code} created. "
|
||||
"The map below is a view of the stored polygon, not a check of it.",
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:origin_zone_update', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class OriginZoneUpdateView(OriginZoneFormContextMixin, OpsRequiredMixin, UpdateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = f"Edit origin zone {self.object.zone_code}"
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(
|
||||
self.request, f"Origin zone {self.object.zone_code} updated."
|
||||
)
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:origin_zone_update', kwargs={'pk': self.object.pk})
|
||||
|
||||
|
||||
class CollectorGroupListView(OpsRequiredMixin, ListView):
|
||||
model = CollectorGroup
|
||||
template_name = 'ops/collector_group_list.html'
|
||||
context_object_name = 'groups'
|
||||
paginate_by = 25
|
||||
|
||||
def get_queryset(self):
|
||||
qs = CollectorGroup.objects.select_related('origin_zone').order_by('name')
|
||||
q = self.request.GET.get('q')
|
||||
if q:
|
||||
qs = qs.filter(name__icontains=q)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['current_q'] = self.request.GET.get('q', '')
|
||||
return ctx
|
||||
|
||||
|
||||
class CollectorGroupFormContextMixin:
|
||||
model = CollectorGroup
|
||||
form_class = CollectorGroupForm
|
||||
template_name = 'ops/register_form.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['cancel_url'] = reverse('ops:collector_group_list')
|
||||
return ctx
|
||||
|
||||
|
||||
class CollectorGroupCreateView(CollectorGroupFormContextMixin, OpsRequiredMixin, CreateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New collector group'
|
||||
ctx['submit_label'] = 'Create group'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Collector group {self.object.name} created.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:collector_group_list')
|
||||
|
||||
|
||||
class CollectorGroupUpdateView(CollectorGroupFormContextMixin, OpsRequiredMixin, UpdateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = f"Edit collector group {self.object.name}"
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, f"Collector group {self.object.name} updated.")
|
||||
return response
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:collector_group_list')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Records that belong to a due-diligence case
|
||||
#
|
||||
# Mitigation actions, EUDR evidence and external-submission references used to
|
||||
# be creatable/editable only in admin (or, for evidence, not at all from the
|
||||
# portal). These screens are reached from the case detail and return to it.
|
||||
|
||||
|
||||
class CaseChildMixin(OpsRequiredMixin):
|
||||
template_name = 'ops/register_form.html'
|
||||
|
||||
def get_case(self):
|
||||
return get_object_or_404(DueDiligenceCase, pk=self.kwargs['case_pk'])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['case'] = self.get_case()
|
||||
ctx['cancel_url'] = reverse(
|
||||
'ops:eudr_detail', kwargs={'pk': self.kwargs['case_pk']}
|
||||
)
|
||||
return ctx
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('ops:eudr_detail', kwargs={'pk': self.kwargs['case_pk']})
|
||||
|
||||
|
||||
class EUDREvidenceCreateView(CaseChildMixin, CreateView):
|
||||
model = EUDREvidence
|
||||
form_class = EUDREvidenceForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Attach evidence — {self.get_case().batch.batch_reference}"
|
||||
)
|
||||
ctx['submit_label'] = 'Attach evidence'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.case = self.get_case()
|
||||
response = super().form_valid(form)
|
||||
# Adding evidence changes the evidence completeness result, so re-run it
|
||||
# exactly as the API does.
|
||||
services.run_completeness_check(self.object.case)
|
||||
messages.success(
|
||||
self.request,
|
||||
"Evidence attached. The case's completeness check has been re-run.",
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class EUDREvidenceUpdateView(CaseChildMixin, UpdateView):
|
||||
model = EUDREvidence
|
||||
form_class = EUDREvidenceForm
|
||||
|
||||
def get_queryset(self):
|
||||
return EUDREvidence.objects.filter(case_id=self.kwargs['case_pk'])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Edit evidence — {self.get_case().batch.batch_reference}"
|
||||
)
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
services.run_completeness_check(self.object.case)
|
||||
messages.success(self.request, 'Evidence updated.')
|
||||
return response
|
||||
|
||||
|
||||
class MitigationActionCreateView(CaseChildMixin, CreateView):
|
||||
model = MitigationAction
|
||||
form_class = MitigationActionForm
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Add mitigation action — {self.get_case().batch.batch_reference}"
|
||||
)
|
||||
ctx['submit_label'] = 'Add action'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
form.instance.case = self.get_case()
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, 'Mitigation action added.')
|
||||
return response
|
||||
|
||||
|
||||
class MitigationActionUpdateView(CaseChildMixin, UpdateView):
|
||||
model = MitigationAction
|
||||
form_class = MitigationActionForm
|
||||
|
||||
def get_queryset(self):
|
||||
return MitigationAction.objects.filter(case_id=self.kwargs['case_pk'])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Edit mitigation action — {self.get_case().batch.batch_reference}"
|
||||
)
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, 'Mitigation action updated.')
|
||||
return response
|
||||
|
||||
|
||||
class StatementReferenceUpdateView(CaseChildMixin, UpdateView):
|
||||
model = DueDiligenceStatementReference
|
||||
form_class = StatementReferenceForm
|
||||
|
||||
def get_queryset(self):
|
||||
return DueDiligenceStatementReference.objects.filter(
|
||||
case_id=self.kwargs['case_pk']
|
||||
)
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Edit submission reference — {self.get_case().batch.batch_reference}"
|
||||
)
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
messages.success(self.request, 'Submission reference updated.')
|
||||
return response
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Product specifications
|
||||
#
|
||||
# A specification is the set of limits every QC result is judged against. The
|
||||
# portal can create and edit a specification and its parameters together, but
|
||||
# the **approval half is held**: status, is_current, approved_by and approved_at
|
||||
# are not on the form, so a portal-created specification is a draft and cannot
|
||||
# be approved here. See apps/ops/forms.py for the reasoning.
|
||||
|
||||
|
||||
class SpecificationListView(OpsRequiredMixin, ListView):
|
||||
model = ProductSpecification
|
||||
template_name = 'ops/specification_list.html'
|
||||
context_object_name = 'specs'
|
||||
paginate_by = 25
|
||||
|
||||
def get_queryset(self):
|
||||
qs = ProductSpecification.objects.prefetch_related('parameters').order_by(
|
||||
'product', '-version'
|
||||
)
|
||||
product = self.request.GET.get('product')
|
||||
status = self.request.GET.get('status')
|
||||
if product:
|
||||
qs = qs.filter(product__icontains=product)
|
||||
if status:
|
||||
qs = qs.filter(status=status)
|
||||
return qs
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['status_choices'] = ProductSpecification.Status.choices
|
||||
ctx['current_product'] = self.request.GET.get('product', '')
|
||||
ctx['current_status'] = self.request.GET.get('status', '')
|
||||
return ctx
|
||||
|
||||
|
||||
class SpecificationFormContextMixin:
|
||||
model = ProductSpecification
|
||||
form_class = SpecificationForm
|
||||
template_name = 'ops/specification_form.html'
|
||||
|
||||
def get_formset(self, data=None):
|
||||
instance = self.object if self.object is not None else ProductSpecification()
|
||||
return SpecParameterFormSet(data, instance=instance, prefix='params')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
if 'formset' not in ctx:
|
||||
ctx['formset'] = self.get_formset()
|
||||
ctx['cancel_url'] = reverse('ops:specification_list')
|
||||
spec = getattr(self, 'object', None)
|
||||
ctx['editing_approved'] = bool(
|
||||
spec is not None
|
||||
and spec.pk
|
||||
and spec.status != ProductSpecification.Status.DRAFT
|
||||
)
|
||||
return ctx
|
||||
|
||||
def forms_valid(self, form, formset):
|
||||
self.object = form.save(commit=False)
|
||||
if self.object._state.adding:
|
||||
# The approval fields are not on the form; a portal-created spec is
|
||||
# always a draft, never current.
|
||||
self.object.status = ProductSpecification.Status.DRAFT
|
||||
self.object.is_current = False
|
||||
self.object.save()
|
||||
formset.instance = self.object
|
||||
formset.save()
|
||||
messages.success(
|
||||
self.request,
|
||||
f"Specification {self.object.product} v{self.object.version} saved.",
|
||||
)
|
||||
return redirect(reverse('ops:specification_list'))
|
||||
|
||||
def forms_invalid(self, form, formset):
|
||||
return self.render_to_response(
|
||||
self.get_context_data(form=form, formset=formset)
|
||||
)
|
||||
|
||||
|
||||
class SpecificationCreateView(SpecificationFormContextMixin, OpsRequiredMixin, CreateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = 'New product specification'
|
||||
ctx['submit_label'] = 'Create specification'
|
||||
return ctx
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = None
|
||||
form = self.get_form()
|
||||
formset = self.get_formset(request.POST)
|
||||
if form.is_valid() and formset.is_valid():
|
||||
return self.forms_valid(form, formset)
|
||||
return self.forms_invalid(form, formset)
|
||||
|
||||
|
||||
class SpecificationUpdateView(SpecificationFormContextMixin, OpsRequiredMixin, UpdateView):
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx['page_title'] = (
|
||||
f"Edit specification {self.object.product} v{self.object.version}"
|
||||
)
|
||||
ctx['submit_label'] = 'Save changes'
|
||||
return ctx
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
self.object = self.get_object()
|
||||
form = self.get_form()
|
||||
formset = self.get_formset(request.POST)
|
||||
if form.is_valid() and formset.is_valid():
|
||||
return self.forms_valid(form, formset)
|
||||
return self.forms_invalid(form, formset)
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
from django.utils import timezone
|
||||
from rest_framework import viewsets, permissions, status
|
||||
from rest_framework import viewsets, 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.permissions import IsOpsOrAdmin
|
||||
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))
|
||||
from .models import QCResult
|
||||
from .serializers import QCResultSerializer, QCResultCreateSerializer
|
||||
|
||||
|
||||
class QCResultViewSet(viewsets.ModelViewSet):
|
||||
queryset = QCResult.objects.all()
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
# Recording a QC result moves the batch's status, quarantines a failure and
|
||||
# can raise a CAPA, so the whole viewset — read and write — is ops/admin
|
||||
# only. The buyer portal reads QC results through the ORM
|
||||
# (apps/buyer/views.py), not this API.
|
||||
permission_classes = [IsOpsOrAdmin]
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "create":
|
||||
|
|
@ -26,7 +26,7 @@ class QCResultViewSet(viewsets.ModelViewSet):
|
|||
def perform_create(self, serializer):
|
||||
serializer.save()
|
||||
|
||||
@action(detail=True, methods=["post"], permission_classes=[IsOpsOrAdmin])
|
||||
@action(detail=True, methods=["post"])
|
||||
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."""
|
||||
|
|
|
|||
|
|
@ -1,9 +1,35 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from .models import Shipment
|
||||
|
||||
|
||||
class ShipmentSerializer(serializers.ModelSerializer):
|
||||
"""Mirror the ops form's rules, so the API cannot accept what the portal refuses.
|
||||
|
||||
The model tolerates a blank vessel or container and a missing date, because
|
||||
records also arrive through this API. But a shipment is what the buyer's page
|
||||
shows, so the fields the buyer reads are required here too, and a departure
|
||||
after the arrival is refused. The bill of lading and the forwarder stay
|
||||
optional; the ports keep their model defaults.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = Shipment
|
||||
fields = "__all__"
|
||||
read_only_fields = ["id", "created_at"]
|
||||
extra_kwargs = {
|
||||
"status": {"required": True},
|
||||
"vessel_name": {"required": True, "allow_blank": False},
|
||||
"container_ref": {"required": True, "allow_blank": False},
|
||||
"etd_nigeria": {"required": True, "allow_null": False},
|
||||
"eta_uk": {"required": True, "allow_null": False},
|
||||
}
|
||||
|
||||
def validate(self, attrs):
|
||||
etd = attrs.get("etd_nigeria", getattr(self.instance, "etd_nigeria", None))
|
||||
eta = attrs.get("eta_uk", getattr(self.instance, "eta_uk", None))
|
||||
if etd and eta and etd > eta:
|
||||
raise serializers.ValidationError(
|
||||
{"eta_uk": "The ETA cannot be before the ETD. Check the two dates."}
|
||||
)
|
||||
return attrs
|
||||
|
|
|
|||
98
apps/shipments/tests.py
Normal file
98
apps/shipments/tests.py
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
"""The shipment API.
|
||||
|
||||
The portal form and the API must agree: the fields the buyer's page reads are
|
||||
required, and a departure after the arrival is refused. The permission boundary
|
||||
itself is swept in ``apps/core/tests.py``; these pin the serializer rules so the
|
||||
API cannot accept what the ops form refuses.
|
||||
"""
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
|
||||
from .models import Shipment
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ShipmentAPITests(TestCase):
|
||||
def setUp(self):
|
||||
self.ops = User.objects.create_user(
|
||||
email="ship-api-ops@ixg.local", password="pass1234", role="ops",
|
||||
full_name="Shipment API Ops", is_staff=True,
|
||||
)
|
||||
self.buyer = User.objects.create_user(
|
||||
email="ship-api-buyer@ixg.local", password="pass1234", role="buyer",
|
||||
full_name="Shipment API Buyer", is_staff=True,
|
||||
)
|
||||
self.batch = ProcessingBatch.objects.create(
|
||||
batch_reference="IXG-BATCH-API-SHIP-001", status="ready_to_ship",
|
||||
)
|
||||
self.shipment = Shipment.objects.create(
|
||||
batch=self.batch, status=Shipment.Status.BOOKED,
|
||||
vessel_name="MV API", container_ref="IXGU0000001",
|
||||
etd_nigeria="2026-09-15", eta_uk="2026-10-15",
|
||||
)
|
||||
self.client = APIClient()
|
||||
|
||||
def payload(self, **overrides):
|
||||
data = {
|
||||
"batch": str(self.batch.pk),
|
||||
"status": "booked",
|
||||
"vessel_name": "MV API",
|
||||
"container_ref": "IXGU0000002",
|
||||
"etd_nigeria": "2026-09-20",
|
||||
"eta_uk": "2026-10-20",
|
||||
}
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
def test_a_buyer_cannot_read_or_write_shipments(self):
|
||||
self.client.force_authenticate(user=self.buyer)
|
||||
self.assertEqual(self.client.get("/api/shipments/").status_code, 403)
|
||||
self.assertEqual(
|
||||
self.client.post("/api/shipments/", self.payload(), format="json").status_code,
|
||||
403,
|
||||
)
|
||||
|
||||
def test_ops_can_create_a_shipment(self):
|
||||
self.client.force_authenticate(user=self.ops)
|
||||
response = self.client.post("/api/shipments/", self.payload(), format="json")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(Shipment.objects.count(), 2)
|
||||
|
||||
def test_each_required_field_is_refused_when_blank(self):
|
||||
self.client.force_authenticate(user=self.ops)
|
||||
for field in (
|
||||
"status", "vessel_name", "container_ref", "etd_nigeria", "eta_uk",
|
||||
):
|
||||
with self.subTest(field=field):
|
||||
response = self.client.post(
|
||||
"/api/shipments/", self.payload(**{field: ""}), format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn(field, response.json())
|
||||
self.assertEqual(Shipment.objects.count(), 1)
|
||||
|
||||
def test_a_departure_after_the_arrival_is_refused(self):
|
||||
self.client.force_authenticate(user=self.ops)
|
||||
response = self.client.post(
|
||||
"/api/shipments/",
|
||||
self.payload(etd_nigeria="2026-11-01", eta_uk="2026-10-01"),
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("eta_uk", response.json())
|
||||
self.assertEqual(Shipment.objects.count(), 1)
|
||||
|
||||
def test_a_partial_status_change_does_not_require_the_other_fields(self):
|
||||
self.client.force_authenticate(user=self.ops)
|
||||
response = self.client.patch(
|
||||
f"/api/shipments/{self.shipment.pk}/",
|
||||
{"status": "in_transit"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.shipment.refresh_from_db()
|
||||
self.assertEqual(self.shipment.status, Shipment.Status.IN_TRANSIT)
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
from rest_framework import viewsets, permissions
|
||||
from rest_framework import viewsets
|
||||
|
||||
from apps.compliance.permissions import IsOpsOrAdmin
|
||||
|
||||
from .models import Shipment
|
||||
from .serializers import ShipmentSerializer
|
||||
|
||||
|
|
@ -6,4 +9,8 @@ from .serializers import ShipmentSerializer
|
|||
class ShipmentViewSet(viewsets.ModelViewSet):
|
||||
queryset = Shipment.objects.all()
|
||||
serializer_class = ShipmentSerializer
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
# The buyer portal reads shipments through the ORM (apps/buyer/views.py), not
|
||||
# this API, so nothing outside ops/admin needs it — including for reads. A
|
||||
# buyer must not be able to create a shipment or rewrite the status its own
|
||||
# page displays.
|
||||
permission_classes = [IsOpsOrAdmin]
|
||||
|
|
|
|||
|
|
@ -1,25 +1,38 @@
|
|||
from mozilla_django_oidc.auth import OIDCAuthenticationBackend
|
||||
|
||||
|
||||
# Roles that carry platform privileges. A buyer is not staff: staff status is
|
||||
# derived from the role, never asserted, so a self-registered buyer cannot come
|
||||
# out as staff (or flip to staff on a later sign-in).
|
||||
STAFF_ROLES = {"ops", "admin"}
|
||||
|
||||
|
||||
def is_staff_for_role(role, *, is_superuser=False):
|
||||
return bool(is_superuser) or role in STAFF_ROLES
|
||||
|
||||
|
||||
class KeycloakOIDCBackend(OIDCAuthenticationBackend):
|
||||
def create_user(self, claims):
|
||||
email = claims.get("email", "")
|
||||
role = claims.get("role", "buyer")
|
||||
if role not in dict(self.UserModel.Roles.choices):
|
||||
role = self.UserModel.Roles.BUYER
|
||||
user = self.UserModel.objects.create_user(
|
||||
email=email,
|
||||
full_name=claims.get("name", ""),
|
||||
keycloak_id=claims.get("sub", ""),
|
||||
role=claims.get("role", "buyer"),
|
||||
is_staff=True,
|
||||
role=role,
|
||||
is_staff=is_staff_for_role(role),
|
||||
)
|
||||
return user
|
||||
|
||||
def update_user(self, user, claims):
|
||||
user.email = claims.get("email", user.email)
|
||||
user.full_name = claims.get("name", user.full_name)
|
||||
user.is_staff = True
|
||||
role = claims.get("role", user.role)
|
||||
if role in dict(self.UserModel.Roles.choices):
|
||||
user.role = role
|
||||
user.is_staff = is_staff_for_role(user.role, is_superuser=user.is_superuser)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
|
|
|||
120
apps/users/keycloak.py
Normal file
120
apps/users/keycloak.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Create Keycloak users from the platform.
|
||||
|
||||
The public buyer sign-up screen needs a Keycloak account before it can write the
|
||||
matching Django row, because that row stores the Keycloak subject id. Realm
|
||||
self-registration is off (``registrationAllowed = false``) and could not capture
|
||||
the company name anyway, so the platform creates the user itself through the
|
||||
Keycloak Admin API, using a dedicated service account
|
||||
(``KEYCLOAK_PROVISIONING_CLIENT_ID``) that holds only ``manage-users`` and
|
||||
``view-users`` on ``realm-management``.
|
||||
|
||||
The account it creates is deliberately bare: **no realm roles and no client
|
||||
roles**, so it grants nothing on the platform. The Django row's ``role`` is the
|
||||
authority; Keycloak is only the identity store.
|
||||
|
||||
Credentials are read from settings (django-environ), never hard-coded, and no
|
||||
token or secret is ever logged.
|
||||
"""
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class KeycloakError(Exception):
|
||||
"""Base class for provisioning failures the sign-up screen can report."""
|
||||
|
||||
|
||||
class DuplicateEmailError(KeycloakError):
|
||||
"""Keycloak already has a user with this email address."""
|
||||
|
||||
|
||||
class KeycloakUnavailableError(KeycloakError):
|
||||
"""Keycloak could not be reached, or refused the service account."""
|
||||
|
||||
|
||||
def _realm_url():
|
||||
return f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}/realms/{settings.KEYCLOAK_REALM}"
|
||||
|
||||
|
||||
def _admin_url(path):
|
||||
return (
|
||||
f"{settings.KEYCLOAK_SERVER_URL.rstrip('/')}"
|
||||
f"/admin/realms/{settings.KEYCLOAK_REALM}{path}"
|
||||
)
|
||||
|
||||
|
||||
def _split_name(full_name):
|
||||
"""Split a full name for Keycloak's required firstName/lastName fields.
|
||||
|
||||
The realm's user profile marks both as required for users, and Keycloak
|
||||
inserts a "Update Account Information" (``VERIFY_PROFILE``) step at sign-in
|
||||
whenever one is empty. A single-word name therefore fills both fields: the
|
||||
platform shows ``full_name`` from its own row, so the split only has to
|
||||
satisfy Keycloak, not be a perfect decomposition.
|
||||
"""
|
||||
parts = full_name.strip().split()
|
||||
if not parts:
|
||||
return "", ""
|
||||
if len(parts) == 1:
|
||||
return parts[0], parts[0]
|
||||
return " ".join(parts[:-1]), parts[-1]
|
||||
|
||||
|
||||
def _service_account_token(timeout=10):
|
||||
"""Exchange the provisioning client's credentials for an admin token."""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{_realm_url()}/protocol/openid-connect/token",
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": settings.KEYCLOAK_PROVISIONING_CLIENT_ID,
|
||||
"client_secret": settings.KEYCLOAK_PROVISIONING_CLIENT_SECRET,
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise KeycloakUnavailableError("Could not reach Keycloak.") from exc
|
||||
if response.status_code != 200:
|
||||
raise KeycloakUnavailableError("Keycloak refused the provisioning account.")
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
def create_keycloak_user(*, email, full_name, password, timeout=10):
|
||||
"""Create an enabled, unverified Keycloak user and return its subject id.
|
||||
|
||||
``emailVerified`` is left false on purpose: the realm has ``verifyEmail``
|
||||
off and no mail service exists, so an unverified user can still sign in.
|
||||
A duplicate email (``duplicateEmailsAllowed`` is off) surfaces as
|
||||
:class:`DuplicateEmailError` rather than a raw 409.
|
||||
"""
|
||||
token = _service_account_token(timeout=timeout)
|
||||
first_name, last_name = _split_name(full_name)
|
||||
payload = {
|
||||
"username": email,
|
||||
"email": email,
|
||||
"firstName": first_name,
|
||||
"lastName": last_name,
|
||||
"enabled": True,
|
||||
"emailVerified": False,
|
||||
"credentials": [
|
||||
{"type": "password", "value": password, "temporary": False}
|
||||
],
|
||||
}
|
||||
try:
|
||||
response = requests.post(
|
||||
_admin_url("/users"),
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise KeycloakUnavailableError("Could not reach Keycloak.") from exc
|
||||
|
||||
if response.status_code == 409:
|
||||
raise DuplicateEmailError(email)
|
||||
if response.status_code not in (201, 204):
|
||||
raise KeycloakUnavailableError("Keycloak could not create the account.")
|
||||
|
||||
location = response.headers.get("Location", "")
|
||||
if not location:
|
||||
raise KeycloakUnavailableError("Keycloak did not return the new account.")
|
||||
return location.rstrip("/").rsplit("/", 1)[-1]
|
||||
32
apps/users/migrations/0003_align_is_staff_with_role.py
Normal file
32
apps/users/migrations/0003_align_is_staff_with_role.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from django.db import migrations
|
||||
|
||||
# Staff status is derived from the role on the write path
|
||||
# (apps/users/auth.py::is_staff_for_role). Existing rows could still disagree —
|
||||
# a seeded buyer carried is_staff=True, and an ops account is_staff=False —
|
||||
# which left a staff buyer inside the admin area until their next sign-in
|
||||
# re-derived the flag. Align the stored flag with the role once, so the flag
|
||||
# never disagrees with the role.
|
||||
|
||||
STAFF_ROLES = ("ops", "admin")
|
||||
|
||||
|
||||
def align_is_staff(apps, schema_editor):
|
||||
User = apps.get_model("users", "User")
|
||||
User.objects.filter(is_superuser=True).update(is_staff=True)
|
||||
User.objects.filter(is_superuser=False, role__in=STAFF_ROLES).update(is_staff=True)
|
||||
User.objects.filter(is_superuser=False).exclude(role__in=STAFF_ROLES).update(
|
||||
is_staff=False
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("users", "0002_alter_user_managers"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Not reversed: the previous flag values are not recoverable, and the
|
||||
# invariant is what we want to keep.
|
||||
migrations.RunPython(align_is_staff, migrations.RunPython.noop),
|
||||
]
|
||||
29
apps/users/oidc.py
Normal file
29
apps/users/oidc.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"""OpenID Connect helpers beyond the authentication backend.
|
||||
|
||||
Signing out has two halves. Clearing the Django session is not enough: Keycloak
|
||||
keeps its own session, so the next request to a protected page silently signs the
|
||||
user back in and the sign-in screen is never shown. ``provider_logout`` is wired
|
||||
to ``OIDC_OP_LOGOUT_URL_METHOD`` and builds the realm's RP-initiated logout URL,
|
||||
which ends the Keycloak session too and returns the browser to the platform root.
|
||||
|
||||
Endpoints come from settings (django-environ); nothing is hard-coded.
|
||||
"""
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def provider_logout(request):
|
||||
"""Return Keycloak's end-session URL for the current session.
|
||||
|
||||
mozilla-django-oidc calls this before flushing the Django session, so the
|
||||
stored id token is still available. ``id_token_hint`` tells Keycloak which
|
||||
session to end, and ``post_logout_redirect_uri`` is where it sends the
|
||||
browser afterwards — that URI must be registered on the client
|
||||
(``post.logout.redirect.uris``) or Keycloak refuses to redirect back.
|
||||
"""
|
||||
id_token = request.session.get("oidc_id_token", "")
|
||||
params = {"post_logout_redirect_uri": request.build_absolute_uri("/")}
|
||||
if id_token:
|
||||
params["id_token_hint"] = id_token
|
||||
return f"{settings.OIDC_OP_LOGOUT_ENDPOINT}?{urlencode(params)}"
|
||||
|
|
@ -6,7 +6,10 @@ class UserSerializer(serializers.ModelSerializer):
|
|||
class Meta:
|
||||
model = User
|
||||
fields = ["id", "email", "full_name", "company_name", "role", "is_active", "created_at"]
|
||||
read_only_fields = ["id", "created_at"]
|
||||
# Role and is_active are never set through this API: a writable role is
|
||||
# how a buyer could promote themselves to admin. They are changed in the
|
||||
# administration area instead.
|
||||
read_only_fields = ["id", "created_at", "role", "is_active"]
|
||||
|
||||
|
||||
class UserDetailSerializer(serializers.ModelSerializer):
|
||||
|
|
|
|||
169
apps/users/tests.py
Normal file
169
apps/users/tests.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""Staff status is derived from the role, never asserted.
|
||||
|
||||
The bug these pin: ``create_user`` and ``update_user`` used to set
|
||||
``is_staff=True`` on every account, so a self-registered buyer came out as staff
|
||||
— and, because ``update_user`` runs on every sign-in, a buyer row created
|
||||
correctly would still flip to staff the first time they signed in. The second
|
||||
case is the one that passes a first test and fails later, so it is covered
|
||||
directly.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from .auth import KeycloakOIDCBackend, is_staff_for_role
|
||||
from .keycloak import _split_name
|
||||
from .oidc import provider_logout
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class SplitNameTests(TestCase):
|
||||
"""Both Keycloak name fields must be non-empty, or sign-in shows the
|
||||
"Update Account Information" (VERIFY_PROFILE) step."""
|
||||
|
||||
def test_a_two_part_name_splits(self):
|
||||
self.assertEqual(_split_name("Ada Greenfield"), ("Ada", "Greenfield"))
|
||||
|
||||
def test_a_multi_part_name_keeps_the_last_word_as_the_surname(self):
|
||||
self.assertEqual(_split_name("Ada Mary Greenfield"), ("Ada Mary", "Greenfield"))
|
||||
|
||||
def test_a_single_word_name_fills_both_fields(self):
|
||||
self.assertEqual(_split_name("Ada"), ("Ada", "Ada"))
|
||||
|
||||
def test_an_empty_name_yields_empty_fields(self):
|
||||
self.assertEqual(_split_name(" "), ("", ""))
|
||||
|
||||
|
||||
class StaffRoleTests(TestCase):
|
||||
def setUp(self):
|
||||
self.backend = KeycloakOIDCBackend()
|
||||
|
||||
def test_only_ops_and_admin_are_staff(self):
|
||||
self.assertFalse(is_staff_for_role("buyer"))
|
||||
self.assertTrue(is_staff_for_role("ops"))
|
||||
self.assertTrue(is_staff_for_role("admin"))
|
||||
self.assertTrue(is_staff_for_role("buyer", is_superuser=True))
|
||||
|
||||
def test_create_user_derives_staff_from_the_claims_role(self):
|
||||
buyer = self.backend.create_user(
|
||||
{"email": "signup-buyer@example.com", "name": "Buyer One", "sub": "sub-buyer", "role": "buyer"}
|
||||
)
|
||||
self.assertEqual(buyer.role, "buyer")
|
||||
self.assertFalse(buyer.is_staff)
|
||||
|
||||
ops = self.backend.create_user(
|
||||
{"email": "signup-ops@example.com", "name": "Ops One", "sub": "sub-ops", "role": "ops"}
|
||||
)
|
||||
self.assertTrue(ops.is_staff)
|
||||
|
||||
def test_create_user_defaults_to_buyer_without_a_role_claim(self):
|
||||
user = self.backend.create_user(
|
||||
{"email": "signup-norole@example.com", "name": "No Role", "sub": "sub-norole"}
|
||||
)
|
||||
self.assertEqual(user.role, "buyer")
|
||||
self.assertFalse(user.is_staff)
|
||||
|
||||
def test_update_user_does_not_staff_a_buyer(self):
|
||||
user = User.objects.create_user(
|
||||
email="buyer-stays@example.com", role="buyer", is_staff=False, keycloak_id="sub-1"
|
||||
)
|
||||
self.backend.update_user(
|
||||
user, {"email": "buyer-stays@example.com", "name": "Buyer", "sub": "sub-1", "role": "buyer"}
|
||||
)
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.role, "buyer")
|
||||
self.assertFalse(user.is_staff)
|
||||
|
||||
def test_update_user_staffs_ops_and_admin(self):
|
||||
for role in ("ops", "admin"):
|
||||
user = User.objects.create_user(
|
||||
email=f"{role}-signin@example.com", role="buyer", is_staff=False,
|
||||
keycloak_id=f"sub-{role}",
|
||||
)
|
||||
self.backend.update_user(
|
||||
user, {"email": user.email, "name": role, "sub": user.keycloak_id, "role": role}
|
||||
)
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.role, role)
|
||||
self.assertTrue(user.is_staff)
|
||||
|
||||
def test_update_user_keeps_the_existing_role_when_the_claim_is_absent(self):
|
||||
user = User.objects.create_user(
|
||||
email="ops-no-role-claim@example.com", role="ops", is_staff=True, keycloak_id="sub-2"
|
||||
)
|
||||
self.backend.update_user(user, {"email": user.email, "name": "Ops", "sub": "sub-2"})
|
||||
user.refresh_from_db()
|
||||
self.assertEqual(user.role, "ops")
|
||||
self.assertTrue(user.is_staff)
|
||||
|
||||
def test_update_user_preserves_a_superuser(self):
|
||||
user = User.objects.create_superuser(
|
||||
email="super@example.com", password="pass1234", role="buyer", keycloak_id="sub-3"
|
||||
)
|
||||
self.backend.update_user(
|
||||
user, {"email": user.email, "name": "Super", "sub": "sub-3", "role": "buyer"}
|
||||
)
|
||||
user.refresh_from_db()
|
||||
self.assertTrue(user.is_superuser)
|
||||
self.assertTrue(user.is_staff)
|
||||
|
||||
|
||||
class LogoutTests(TestCase):
|
||||
"""Signing out must end the Keycloak session, not just the Django one.
|
||||
|
||||
The bug: the base templates linked to the logout view with a GET, which
|
||||
mozilla-django-oidc refuses (405); and even a successful POST only cleared
|
||||
the Django session, so Keycloak silently signed the user back in and the
|
||||
sign-in screen could never be reached again. These pin all three parts: GET
|
||||
is refused, POST clears the session, and the URL handed to the browser ends
|
||||
the Keycloak session and carries a registered return trip.
|
||||
"""
|
||||
|
||||
def test_get_logout_is_refused(self):
|
||||
response = self.client.get(reverse("oidc_logout"))
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_post_logout_clears_the_django_session(self):
|
||||
user = User.objects.create_user(
|
||||
email="logout-ops@example.com", role="ops", is_staff=True
|
||||
)
|
||||
self.client.force_login(user)
|
||||
response = self.client.post(reverse("oidc_logout"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertNotIn("_auth_user_id", self.client.session)
|
||||
|
||||
def test_logout_url_targets_the_realm_end_session_endpoint(self):
|
||||
request = RequestFactory().get("/portal/")
|
||||
request.session = {"oidc_id_token": "id-token-abc"}
|
||||
url = provider_logout(request)
|
||||
self.assertTrue(url.startswith(settings.OIDC_OP_LOGOUT_ENDPOINT))
|
||||
self.assertIn("id_token_hint=id-token-abc", url)
|
||||
self.assertIn("post_logout_redirect_uri=", url)
|
||||
self.assertIn(quote("http://testserver/", safe=""), url)
|
||||
|
||||
def test_logout_url_omits_the_hint_when_no_token_is_stored(self):
|
||||
request = RequestFactory().get("/portal/")
|
||||
request.session = {}
|
||||
self.assertNotIn("id_token_hint", provider_logout(request))
|
||||
|
||||
def test_the_buyer_portal_renders_a_post_logout_form(self):
|
||||
buyer = User.objects.create_user(
|
||||
email="logout-buyer@example.com", role="buyer", is_staff=False
|
||||
)
|
||||
self.client.force_login(buyer)
|
||||
response = self.client.get("/portal/")
|
||||
self.assertContains(response, 'action="/oidc/logout/"')
|
||||
self.assertContains(response, "csrfmiddlewaretoken")
|
||||
|
||||
def test_the_ops_portal_renders_a_post_logout_form(self):
|
||||
ops = User.objects.create_user(
|
||||
email="logout-ops2@example.com", role="ops", is_staff=True
|
||||
)
|
||||
self.client.force_login(ops)
|
||||
response = self.client.get("/ops/")
|
||||
self.assertContains(response, 'action="/oidc/logout/"')
|
||||
self.assertContains(response, "csrfmiddlewaretoken")
|
||||
|
|
@ -1,4 +1,7 @@
|
|||
from rest_framework import viewsets, mixins, permissions
|
||||
|
||||
from apps.compliance.permissions import IsAdmin
|
||||
|
||||
from .models import User
|
||||
from .serializers import UserSerializer, UserDetailSerializer, UserCreateSerializer
|
||||
|
||||
|
|
@ -9,8 +12,18 @@ class UserViewSet(mixins.ListModelMixin,
|
|||
viewsets.GenericViewSet):
|
||||
queryset = User.objects.all()
|
||||
serializer_class = UserSerializer
|
||||
# A signed-in user may read their own record (get_queryset scopes a
|
||||
# non-admin to it) but must not be able to write one. Without this, a buyer
|
||||
# could PATCH their own row and set role to "admin", which would then pass
|
||||
# every IsOpsOrAdmin check on the platform. Role and is_active are read-only
|
||||
# in the serializer as well, so no API caller can change them.
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action in ("create", "update", "partial_update", "destroy"):
|
||||
return [IsAdmin()]
|
||||
return super().get_permissions()
|
||||
|
||||
def get_serializer_class(self):
|
||||
if self.action == "retrieve":
|
||||
return UserDetailSerializer
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ services:
|
|||
condition: service_healthy
|
||||
volumes:
|
||||
- .:/app
|
||||
- ./staticfiles:/app/staticfiles
|
||||
- /var/www/ixg-platform-static:/app/staticfiles
|
||||
- ./media:/app/media
|
||||
- ./documents:/data/ixg-documents
|
||||
env_file:
|
||||
|
|
@ -55,7 +55,7 @@ services:
|
|||
DB_HOST: ixg-postgres
|
||||
DB_PORT: 5432
|
||||
ports:
|
||||
- "0.0.0.0:8000:8000"
|
||||
- "127.0.0.1:8000:8000"
|
||||
|
||||
celery_worker:
|
||||
build: .
|
||||
|
|
|
|||
212
docs/USER_GUIDES_VIEWER_SPEC.md
Normal file
212
docs/USER_GUIDES_VIEWER_SPEC.md
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
# User Guides Viewer (Help Centre) — Implementation Spec
|
||||
|
||||
Status: Ready for implementation (approved by IXG)
|
||||
Author: Hermes (ixg-docs) — for OpenCode to implement
|
||||
Repo: /root/work/ixg_platform (Django 5.2, project package `dsp_platform`, apps under `apps/`)
|
||||
Date: 07 Aug 2026
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
A **read-only, role-gated help centre** inside the IXG platform that shows the
|
||||
user guides authored in Wiki.js:
|
||||
|
||||
- Buyers: `/portal/help/` — the buyer guides
|
||||
- Ops users: `/ops/help/` — the ops guides (+ admin guides for admin role)
|
||||
- Admin users: can reach both portals (admin can already access `/portal/`
|
||||
and `/ops/` per RBAC), so they see everything.
|
||||
|
||||
**The Wiki.js pages under `user-guides/` are the single source of truth.**
|
||||
This feature is a one-way read/sync: the platform never writes to the wiki.
|
||||
|
||||
---
|
||||
|
||||
## 2. Source of truth (Wiki.js)
|
||||
|
||||
Pages live in the `wikijs` PostgreSQL database (same server, port 5432):
|
||||
|
||||
- Table `pages` — columns of interest: `path` (e.g. `user-guides/buyer/getting-started`, **no locale prefix**), `title`, `content` (markdown), `description`, `"isPublished"`, `"isPrivate"`, `"updatedAt"`.
|
||||
- The `user-guides` tree:
|
||||
|
||||
```
|
||||
user-guides/index (shared landing)
|
||||
user-guides/buyer/* 7 buyer guides
|
||||
user-guides/ops/* 9 ops guides
|
||||
user-guides/admin/* 2 admin guides
|
||||
```
|
||||
|
||||
Audience mapping (by path prefix):
|
||||
|
||||
| Prefix | Audience |
|
||||
|--------|----------|
|
||||
| `user-guides/index` | `shared` |
|
||||
| `user-guides/buyer/` | `buyer` |
|
||||
| `user-guides/ops/` | `ops` |
|
||||
| `user-guides/admin/` | `admin` |
|
||||
|
||||
---
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### 3.1 New app `apps/guides`
|
||||
|
||||
Register in `INSTALLED_APPS` (settings.py). Contains:
|
||||
|
||||
**Model `GuidePage`**
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `path` | CharField, unique | wiki path, e.g. `user-guides/buyer/getting-started` |
|
||||
| `title` | CharField | from wiki title |
|
||||
| `audience` | CharField(choices: shared/buyer/ops/admin) | mapped from path |
|
||||
| `content` | TextField | markdown body from wiki |
|
||||
| `updated_at` | DateTimeField | wiki `updatedAt` |
|
||||
| `synced_at` | DateTimeField | when we last synced |
|
||||
|
||||
**Management command `sync_guides`** (apps/guides/management/commands/sync_guides.py):
|
||||
|
||||
1. Connect to the `wikijs` DB alias (read-only).
|
||||
2. `SELECT path, title, content, "updatedAt" FROM pages WHERE path LIKE 'user-guides/%' AND "isPublished" = true AND "isPrivate" = false`
|
||||
3. Map audience from path prefix; upsert into `GuidePage` by `path`.
|
||||
4. Delete `GuidePage` rows whose path no longer exists in the wiki (removed pages disappear from the help centre).
|
||||
5. Print a summary (created / updated / removed counts).
|
||||
|
||||
**Views** — role-scoped querysets, both mounted under the two portal namespaces:
|
||||
|
||||
- `GuideIndexView` — lists guides the current user's role may see.
|
||||
- `GuideDetailView` — renders one guide; 404 if the page's audience is not
|
||||
allowed for the current user (defence in depth — the URL routes are already
|
||||
role-gated by RBACMiddleware, but never rely on that alone).
|
||||
|
||||
**Scoping is by PORTAL first, then by role** (revised 16 Sep 2026). Which
|
||||
guides are listed depends on the portal you are standing in, not only on who
|
||||
you are: the buyer portal is for buyers, so it shows buyer guides even to an
|
||||
admin. Admin-only guides are ops-side material and never appear in the buyer
|
||||
portal. Implemented as `apps.guides.models.allowed_audiences(portal, role,
|
||||
is_superuser)` — the single place the rule lives.
|
||||
|
||||
| Portal | Role | Allowed guide audiences |
|
||||
|--------|------|-------------------------|
|
||||
| `/portal/help/` | buyer | shared, buyer |
|
||||
| `/portal/help/` | admin / superuser | shared, buyer (no change) |
|
||||
| `/ops/help/` | ops | shared, ops |
|
||||
| `/ops/help/` | admin / superuser | shared, ops, admin |
|
||||
|
||||
Note the role gate on the portal itself (`BuyerRequiredMixin` /
|
||||
`OpsRequiredMixin`): only `buyer` + `admin` reach `/portal/`, only `ops` +
|
||||
`admin` reach `/ops/`. So the two viewers portal scoping actually
|
||||
distinguishes are real buyers and admins-who-can-see-everything.
|
||||
|
||||
**Markdown rendering** (in the view or a small helper `apps/guides/markdown.py`):
|
||||
|
||||
- `markdown.markdown(content, extensions=["tables", "fenced_code", "sane_lists"])`
|
||||
- Sanitise with `bleach.clean(html, tags=[...], attributes=[...], protocols=["http","https","mailto"])` — allowed tags: `p br strong em ul ol li table thead tbody tr th td h1 h2 h3 h4 a code pre blockquote hr img span div`.
|
||||
- **Link rewriting**: internal guide links in the wiki are relative markdown
|
||||
links like `[Compliance guide](compliance)` or `[Compliance guide](compliance)`
|
||||
(same-folder) — rewrite them to the current help root
|
||||
(`/portal/help/<page-path-suffix>/` or `/ops/help/...`). Wiki image paths
|
||||
starting with `/buyer-guide/` or `/ops-guide/` should be rewritten to the
|
||||
wiki public base URL (setting `WIKI_PUBLIC_BASE`, default
|
||||
`http://194.164.95.50`).
|
||||
- Note: markdown-it-anchor headings are NOT available in this Wiki.js build,
|
||||
so the guides avoid anchor links — no anchor handling needed.
|
||||
|
||||
### 3.2 Database access to `wikijs` (read-only)
|
||||
|
||||
- settings.py `DATABASES` gains a second alias `wikijs`, populated from env via
|
||||
django-environ (same pattern as the default DB):
|
||||
`WIKI_DB_NAME`, `WIKI_DB_HOST`, `WIKI_DB_PORT`, `WIKI_DB_USER`, `WIKI_DB_PASSWORD`.
|
||||
- Create a **read-only** PostgreSQL role (run once as postgres, e.g. via
|
||||
`docker exec postgres psql -U postgres`):
|
||||
|
||||
```sql
|
||||
CREATE ROLE wikijs_ro LOGIN PASSWORD '<generate-a-strong-password>';
|
||||
GRANT CONNECT ON DATABASE wikijs TO wikijs_ro;
|
||||
GRANT SELECT ON ALL TABLES IN SCHEMA public TO wikijs_ro;
|
||||
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO wikijs_ro;
|
||||
```
|
||||
|
||||
- Put the password ONLY in `config/secrets/env.production` (never in code,
|
||||
wiki pages, or this spec).
|
||||
|
||||
### 3.3 URL wiring
|
||||
|
||||
- `apps/buyer/urls.py` (namespace `portal`):
|
||||
`path("help/", GuideIndexView, name="guide_index")`
|
||||
`path("help/<str:page_path>/", GuideDetailView, name="guide_detail")`
|
||||
- `apps/ops/urls.py` (namespace `ops`): same two routes.
|
||||
- Add a **Help** nav item to both base templates:
|
||||
- `templates/portal/base_portal.html` sidebar (`bi bi-book` icon) → `{% url 'portal:guide_index' %}`
|
||||
- `templates/ops/base_ops.html` sidebar (`bi bi-book` icon) → `{% url 'ops:guide_index' %}`
|
||||
|
||||
### 3.4 Templates (professional manual look)
|
||||
|
||||
- `templates/guides/`:
|
||||
- `index_portal.html` — extends `portal/base_portal.html`, includes shared partial
|
||||
- `index_ops.html` — extends `ops/base_ops.html`, includes shared partial
|
||||
- `detail_portal.html` / `detail_ops.html` — same split
|
||||
- Shared partials `_guide_list.html` (index: cards or a clean list with
|
||||
titles + first paragraph preview) and `_guide_detail.html` (two-column:
|
||||
left = guide list for the role, right = rendered content, with the page
|
||||
title as heading and a breadcrumb/back link).
|
||||
- Use existing Bootstrap + stat-card styling so it looks native to each portal.
|
||||
- Empty state: "No guides available yet" when the list is empty.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sync scheduling
|
||||
|
||||
Primary: host crontab (no app-level scheduler needed):
|
||||
|
||||
```
|
||||
# every 6 hours
|
||||
0 */6 * * * docker exec ixg-platform-app python manage.py sync_guides >> /var/log/ixg-guides-sync.log 2>&1
|
||||
```
|
||||
|
||||
Manual refresh: `docker exec ixg-platform-app python manage.py sync_guides`.
|
||||
|
||||
Optional alternative (do NOT do both): a Celery beat task calling the same
|
||||
command. Pick cron.
|
||||
|
||||
---
|
||||
|
||||
## 5. Acceptance criteria
|
||||
|
||||
1. `sync_guides` populates `GuidePage` from the wiki (verified by running it
|
||||
and querying the model).
|
||||
2. `/portal/help/` lists the 7 buyer guides + the shared landing page, and
|
||||
**nothing else** — for every role that can reach it, including admin.
|
||||
Opening one renders tables, headings and the embedded screenshots.
|
||||
3. `/ops/help/` lists the 9 ops guides + the shared landing page; an admin
|
||||
user also sees the 2 admin guides (12 rows).
|
||||
4. A buyer user cannot see an ops/admin guide (route + audience filter; 404),
|
||||
and an admin user cannot reach an ops guide through `/portal/help/` (404,
|
||||
available via `/ops/help/` instead).
|
||||
5. Internal guide links resolve within the help centre; images load from the
|
||||
wiki public base.
|
||||
6. The sync never writes to the `wikijs` database (read-only role enforced).
|
||||
7. `python manage.py test` passes (add tests: audience filtering, markdown
|
||||
sanitisation strips `<script>`, link rewriting, sync command mapping).
|
||||
8. Existing portal/ops pages unchanged apart from the added Help nav item.
|
||||
|
||||
---
|
||||
|
||||
## 6. Out of scope
|
||||
|
||||
- Any write path back to the wiki.
|
||||
- Public (unauthenticated) access, subdomains, or a separate docs app.
|
||||
- Translating guides, theming, or search (nice-to-haves for later).
|
||||
- The admin area (`/admin/`) itself — admins use `/ops/help/` for guides.
|
||||
|
||||
---
|
||||
|
||||
## 7. Implementation notes
|
||||
|
||||
- Deps already present: `markdown==3.7`, `bleach==6.2.0`, `psycopg2-binary`.
|
||||
- Settings uses django-environ; env file `config/secrets/env.production`.
|
||||
- Project runs in Docker (`ixg-platform-app`); migrations run via
|
||||
`docker exec ixg-platform-app python manage.py migrate`.
|
||||
- Keep the audience mapping table in one place (model method or constant) so
|
||||
index/detail views share it.
|
||||
- Do not hardcode the wiki DB password anywhere; env only.
|
||||
|
|
@ -17,6 +17,22 @@ SECRET_KEY = env("DJANGO_SECRET_KEY", default="insecure-dev-key-change-in-produc
|
|||
DEBUG = env.bool("DJANGO_DEBUG", default=False)
|
||||
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["194.164.95.50", "localhost", "127.0.0.1"])
|
||||
|
||||
# Trust nginx TLS termination headers
|
||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SESSION_COOKIE_SECURE = env.bool("SESSION_COOKIE_SECURE", default=True)
|
||||
CSRF_COOKIE_SECURE = env.bool("CSRF_COOKIE_SECURE", default=True)
|
||||
CSRF_TRUSTED_ORIGINS = env.list(
|
||||
"DJANGO_CSRF_TRUSTED_ORIGINS",
|
||||
default=[
|
||||
"https://ixgltd.com",
|
||||
"https://www.ixgltd.com",
|
||||
"https://app.ixgltd.com",
|
||||
"https://portal.ixgltd.com",
|
||||
"https://auth.ixgltd.com",
|
||||
],
|
||||
)
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"grappelli",
|
||||
"django.contrib.admin",
|
||||
|
|
@ -46,6 +62,7 @@ INSTALLED_APPS = [
|
|||
"apps.notifications",
|
||||
"apps.ops",
|
||||
"apps.buyer",
|
||||
"apps.guides",
|
||||
"encrypted_model_fields",
|
||||
]
|
||||
|
||||
|
|
@ -89,7 +106,15 @@ DATABASES = {
|
|||
"PASSWORD": env("DB_PASSWORD", default=""),
|
||||
"HOST": env("DB_HOST", default="127.0.0.1"),
|
||||
"PORT": env.int("DB_PORT", default=5432),
|
||||
}
|
||||
},
|
||||
"wikijs": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": env("WIKI_DB_NAME", default="wikijs"),
|
||||
"USER": env("WIKI_DB_USER", default=""),
|
||||
"PASSWORD": env("WIKI_DB_PASSWORD", default=""),
|
||||
"HOST": env("WIKI_DB_HOST", default="127.0.0.1"),
|
||||
"PORT": env.int("WIKI_DB_PORT", default=5432),
|
||||
},
|
||||
}
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
|
|
@ -106,6 +131,30 @@ STATIC_ROOT = BASE_DIR / "staticfiles"
|
|||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = BASE_DIR / "media"
|
||||
|
||||
WIKI_PUBLIC_BASE = env("WIKI_PUBLIC_BASE", default="http://194.164.95.50")
|
||||
|
||||
# Tile source for the Leaflet map showing origin zones (apps/compliance/admin.py).
|
||||
# Self-host this, or use a licensed provider, before it carries production
|
||||
# traffic: OSM's public tile servers are governed by their usage policy and are
|
||||
# only suitable for internal, low-volume use. Note also that any hosted tile
|
||||
# source sees the areas being viewed — which, for this platform, are the places
|
||||
# IXG sources shea.
|
||||
LEAFLET_TILE_URL = env(
|
||||
"LEAFLET_TILE_URL", default="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
)
|
||||
|
||||
# Serve tiles from our own origin instead of letting the browser fetch them from
|
||||
# a third party (apps/core/tiles.py). The map is the only part of the admin page
|
||||
# that depends on an external domain, and when a viewer's network, proxy or
|
||||
# browser filter refuses that domain the map silently never draws — the page,
|
||||
# the Leaflet bundle and the polygon are all same-origin and fine. With this on,
|
||||
# the browser asks us and we fetch and cache. Set LEAFLET_TILE_PROXY_ENABLED=False
|
||||
# to go back to direct browser fetches.
|
||||
LEAFLET_TILE_PROXY_ENABLED = env.bool("LEAFLET_TILE_PROXY_ENABLED", default=True)
|
||||
LEAFLET_TILE_CACHE_DIR = env(
|
||||
"LEAFLET_TILE_CACHE_DIR", default=str(MEDIA_ROOT / "tile-cache")
|
||||
)
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
AUTH_USER_MODEL = "users.User"
|
||||
|
|
@ -130,11 +179,24 @@ OIDC_STORE_ACCESS_TOKEN = True
|
|||
OIDC_STORE_ID_TOKEN = True
|
||||
LOGIN_URL = "oidc_authentication_init"
|
||||
LOGOUT_REDIRECT_URL = "/"
|
||||
LOGIN_REDIRECT_URL = "/admin/"
|
||||
# RP-initiated logout: end the Keycloak session as well as the Django one, or
|
||||
# the next request silently re-authenticates (apps/users/oidc.py).
|
||||
OIDC_OP_LOGOUT_URL_METHOD = "apps.users.oidc.provider_logout"
|
||||
# The root view dispatches by role (apps/core/views.RoleRedirectView), so a
|
||||
# buyer who signs in is not aimed at the admin area, and the RBAC middleware
|
||||
# can redirect a wrong-role request here without looping.
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
KEYCLOAK_SERVER_URL = env("KEYCLOAK_SERVER_URL", default="http://194.164.95.50:8443")
|
||||
KEYCLOAK_REALM = env("KEYCLOAK_REALM", default="ixg")
|
||||
|
||||
# Service account used by the public buyer sign-up screen to create Keycloak
|
||||
# users through the Admin API (apps/users/keycloak.py). It holds only
|
||||
# realm-management's manage-users and view-users. Secrets come from the
|
||||
# environment and are never written to code, logs or the wiki.
|
||||
KEYCLOAK_PROVISIONING_CLIENT_ID = env("KEYCLOAK_PROVISIONING_CLIENT_ID", default="")
|
||||
KEYCLOAK_PROVISIONING_CLIENT_SECRET = env("KEYCLOAK_PROVISIONING_CLIENT_SECRET", default="")
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
from django.contrib import admin
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import path, include
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from rest_framework.authtoken import views as drf_auth
|
||||
|
||||
from apps.core.tiles import tile_view
|
||||
from apps.core.views import RoleRedirectView
|
||||
|
||||
urlpatterns = [
|
||||
path("", lambda r: HttpResponseRedirect("/admin/")),
|
||||
path("", RoleRedirectView.as_view(), name="home"),
|
||||
path("tiles/<int:z>/<int:x>/<int:y>.png", tile_view, name="tile"),
|
||||
path("grappelli/", include("grappelli.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
path("oidc/", include("mozilla_django_oidc.urls")),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ python manage.py migrate --noinput || true
|
|||
echo "Collecting static files..."
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
if [ "$#" -gt 0 ]; then
|
||||
echo "Executing command: $*"
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
echo "Starting Gunicorn..."
|
||||
exec gunicorn --bind 0.0.0.0:8000 \
|
||||
--workers 2 \
|
||||
|
|
|
|||
22
templates/403.html
Normal file
22
templates/403.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en-gb">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Not allowed</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #f0fdf4; color: #1f2937; }
|
||||
.wrap { max-width: 34rem; margin: 12vh auto; padding: 0 1.5rem; text-align: center; }
|
||||
h1 { font-size: 1.5rem; color: #065f46; margin: 0 0 .75rem; }
|
||||
p { line-height: 1.5; color: #475569; }
|
||||
a { color: #065f46; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Not allowed</h1>
|
||||
<p>Your account does not have access to that page.</p>
|
||||
<p><a href="/">Return to the start</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
22
templates/404.html
Normal file
22
templates/404.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en-gb">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Page not found</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #f0fdf4; color: #1f2937; }
|
||||
.wrap { max-width: 34rem; margin: 12vh auto; padding: 0 1.5rem; text-align: center; }
|
||||
h1 { font-size: 1.5rem; color: #065f46; margin: 0 0 .75rem; }
|
||||
p { line-height: 1.5; color: #475569; }
|
||||
a { color: #065f46; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Page not found</h1>
|
||||
<p>The page you asked for is not here. Check the address, or return to the start.</p>
|
||||
<p><a href="/">Return to the start</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
23
templates/500.html
Normal file
23
templates/500.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en-gb">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Something went wrong</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; background: #f0fdf4; color: #1f2937; }
|
||||
.wrap { max-width: 34rem; margin: 12vh auto; padding: 0 1.5rem; text-align: center; }
|
||||
h1 { font-size: 1.5rem; color: #065f46; margin: 0 0 .75rem; }
|
||||
p { line-height: 1.5; color: #475569; }
|
||||
a { color: #065f46; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>The platform hit an unexpected error and could not finish that request.
|
||||
It has been recorded. Please try again in a moment.</p>
|
||||
<p><a href="/">Return to the start</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
28
templates/guides/_guide_detail.html
Normal file
28
templates/guides/_guide_detail.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<div class="page-header">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-1">
|
||||
<li class="breadcrumb-item"><a href="{{ help_root }}">Help Centre</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ guide.title }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<h2>{{ guide.title }}</h2>
|
||||
<a href="{{ help_root }}" class="btn btn-sm btn-outline-secondary mt-2">← Back to all guides</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">Guides</h6>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
{% for item in guides %}
|
||||
<li class="mb-2">
|
||||
<a class="text-decoration-none {% if item.pk == guide.pk %}fw-bold text-primary{% else %}text-secondary{% endif %}" href="{{ help_root }}{{ item.page_slug }}/">{{ item.title }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<div class="stat-card guide-content">{{ guide_html|safe }}</div>
|
||||
</div>
|
||||
</div>
|
||||
26
templates/guides/_guide_list.html
Normal file
26
templates/guides/_guide_list.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Help Centre</h2>
|
||||
<span class="text-secondary">User guides</span>
|
||||
</div>
|
||||
|
||||
{% if guides %}
|
||||
<div class="row g-3">
|
||||
{% for guide in guides %}
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<h6 class="mb-0"><a class="text-decoration-none" href="{{ help_root }}{{ guide.page_slug }}/">{{ guide.title }}</a></h6>
|
||||
<span class="badge bg-light text-secondary border">{{ guide.get_audience_display }}</span>
|
||||
</div>
|
||||
{% if guide.preview %}
|
||||
<p class="text-muted mb-0 small">{{ guide.preview }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="stat-card text-center py-5">
|
||||
<p class="text-muted mb-0">No guides available yet.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
5
templates/guides/detail_ops.html
Normal file
5
templates/guides/detail_ops.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}{{ guide.title }} - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'guides/_guide_detail.html' %}
|
||||
{% endblock %}
|
||||
5
templates/guides/detail_portal.html
Normal file
5
templates/guides/detail_portal.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{% extends 'portal/base_portal.html' %}
|
||||
{% block title %}{{ guide.title }} - IXG Buyer Portal{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'guides/_guide_detail.html' %}
|
||||
{% endblock %}
|
||||
5
templates/guides/index_ops.html
Normal file
5
templates/guides/index_ops.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Help Centre - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'guides/_guide_list.html' %}
|
||||
{% endblock %}
|
||||
5
templates/guides/index_portal.html
Normal file
5
templates/guides/index_portal.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{% extends 'portal/base_portal.html' %}
|
||||
{% block title %}Help Centre - IXG Buyer Portal{% endblock %}
|
||||
{% block content %}
|
||||
{% include 'guides/_guide_list.html' %}
|
||||
{% endblock %}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content=width=device-width, initial-scale=1>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}IXG Operations{% endblock %}</title>
|
||||
<link href=https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css rel=stylesheet>
|
||||
<link rel=stylesheet href=https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<style>
|
||||
body { font-size: .875rem; background: #f5f6fa; }
|
||||
.sidebar { position: fixed; top: 0; bottom: 0; left: 0; z-index: 100; padding: 48px 0 0; width: 240px; background: #1e2a3a; }
|
||||
|
|
@ -23,40 +23,58 @@ body { font-size: .875rem; background: #f5f6fa; }
|
|||
.page-header { margin-bottom: 1.5rem; }
|
||||
.page-header h2 { font-weight: 600; }
|
||||
.filter-bar { background: #fff; border-radius: .5rem; padding: 1rem; border: 1px solid #e2e8f0; margin-bottom: 1.5rem; }
|
||||
.guide-content img { max-width: 100%; height: auto; border-radius: .375rem; }
|
||||
.guide-content table { width: 100%; border-collapse: collapse; margin-bottom: 1rem; }
|
||||
.guide-content th, .guide-content td { border: 1px solid #e2e8f0; padding: .5rem .75rem; }
|
||||
.guide-content pre { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: .375rem; padding: .75rem; }
|
||||
.guide-content blockquote { border-left: 4px solid #a7f3d0; padding-left: 1rem; color: #475569; }
|
||||
</style>
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class=sidebar>
|
||||
<div class=brand>IXG Platform</div>
|
||||
<ul class=nav flex-column>
|
||||
<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>
|
||||
<li class=nav-item><a class=nav-link {% if 'esg' in request.resolver_match.url_name %}active{% endif %} href={% url 'ops:esg_list' %}><i class=bi bi-tree></i> ESG Reports</a></li>
|
||||
<div class="sidebar">
|
||||
<div class="brand">IXG Platform</div>
|
||||
<ul class="nav flex-column">
|
||||
<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 'intake' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:intake_list' %}"><i class="bi bi-basket"></i> Harvest Intakes</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'facility' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:facility_list' %}"><i class="bi bi-building"></i> Facilities</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'origin_zone' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:origin_zone_list' %}"><i class="bi bi-geo-alt"></i> Origin Zones</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'collector' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:collector_group_list' %}"><i class="bi bi-people"></i> Collector Groups</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'specification' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:specification_list' %}"><i class="bi bi-clipboard-data"></i> Specifications</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>
|
||||
<li class="nav-item"><a class="nav-link {% if 'esg' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:esg_list' %}"><i class="bi bi-tree"></i> ESG Reports</a></li>
|
||||
<li class="nav-item"><a class="nav-link {% if 'guide' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'ops:guide_index' %}"><i class="bi bi-book"></i> Help</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class=main>
|
||||
<div class=topbar>
|
||||
<div class="main">
|
||||
<div class="topbar">
|
||||
<span></span>
|
||||
<div class=d-flex align-items-center gap-3>
|
||||
<a href=/admin/ class=text-decoration-none text-secondary><i class=bi bi-shield-lock></i> Admin</a>
|
||||
<span class=text-secondary>{{ request.user.full_name|default:request.user.email }}</span>
|
||||
<a href={% url 'oidc_logout' %} class=btn btn-sm btn-outline-secondary>Logout</a>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
{% if request.user.role == 'admin' or request.user.is_superuser %}
|
||||
<a href="/admin/" class="text-decoration-none text-secondary"><i class="bi bi-shield-lock"></i> Admin</a>
|
||||
{% endif %}
|
||||
<span class="text-secondary">{{ request.user.full_name|default:request.user.email }}</span>
|
||||
<form method="post" action="{% url 'oidc_logout' %}" class="m-0">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class=content>
|
||||
<div class="content">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src=https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,44 +2,115 @@
|
|||
{% block title %}{{ batch.batch_reference }} - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Batch: {{ batch.batch_reference }}</h2>
|
||||
<a href={% url 'ops:batch_list' %} class=btn btn-sm btn-outline-secondary><i class=bi bi-arrow-left></i> Back</a>
|
||||
<div>
|
||||
<a href="{% url 'ops:batch_lineage' batch.pk %}" class="btn btn-sm btn-primary"><i class="bi bi-basket"></i> Edit intake lineage</a>
|
||||
<a href="{% url 'ops:batch_update' batch.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-pencil"></i> Edit batch</a>
|
||||
<a href="{% url 'ops:batch_list' %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=row g-3 mb-4>
|
||||
<div class=col-md-6>
|
||||
<div class=stat-card>
|
||||
<table class=table table-sm table-borderless mb-0>
|
||||
<tr><td class=text-secondary>Status</td><td><span class=badge bg-secondary badge-status>{{ batch.get_status_display }}</span></td></tr>
|
||||
<tr><td class=text-secondary>Grade</td><td>{{ batch.grade|default:'Not graded' }}</td></tr>
|
||||
<tr><td class=text-secondary>Weight</td><td>{{ batch.weight_kg|default:'-' }} kg</td></tr>
|
||||
<tr><td class=text-secondary>Origin Facility</td><td>{{ batch.origin_facility }}</td></tr>
|
||||
<tr><td class=text-secondary>Harvest Date</td><td>{{ batch.harvest_date|date:'d/m/Y'|default:'-' }}</td></tr>
|
||||
<tr><td class=text-secondary>Created By</td><td>{{ batch.created_by.full_name|default:batch.created_by.email|default:'-' }}</td></tr>
|
||||
<tr><td class=text-secondary>Created At</td><td>{{ batch.created_at|date:'d/m/Y H:i' }}</td></tr>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tr><td class="text-secondary">Status</td><td><span class="badge bg-secondary badge-status">{{ batch.get_status_display }}</span></td></tr>
|
||||
<tr><td class="text-secondary">Grade</td><td>{{ batch.grade|default:'Not graded' }}</td></tr>
|
||||
<tr><td class="text-secondary">Weight</td><td>{{ batch.weight_kg|default:'-' }} kg</td></tr>
|
||||
<tr><td class="text-secondary">Origin Facility</td><td>{{ batch.origin_facility }}</td></tr>
|
||||
<tr><td class="text-secondary">Harvest Date</td><td>{{ batch.harvest_date|date:'d/m/Y'|default:'-' }}</td></tr>
|
||||
<tr><td class="text-secondary">Created By</td><td>{% if batch.created_by %}{{ batch.created_by.full_name|default:batch.created_by.email }}{% else %}-{% endif %}</td></tr>
|
||||
<tr><td class="text-secondary">Created At</td><td>{{ batch.created_at|date:'d/m/Y H:i' }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class=col-md-6>
|
||||
<div class=stat-card>
|
||||
<h6 class=mb-3>Notes</h6>
|
||||
<p class=mb-0>{{ batch.notes|linebreaks|default:'<span class=text-muted>No notes</span>' }}</p>
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">Notes</h6>
|
||||
<p class="mb-0">{{ batch.notes|linebreaks|default:'<span class=text-muted>No notes</span>' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=row g-3 mb-4>
|
||||
<div class=col-md-6>
|
||||
<div class=stat-card>
|
||||
<h6 class=mb-3>QC Results ({{ qc_results|length }})</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="stat-card">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Intake Lineage ({{ lineage|length }})</h6>
|
||||
<a href="{% url 'ops:batch_lineage' batch.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-basket"></i> Edit lineage</a>
|
||||
</div>
|
||||
{% if lineage %}
|
||||
<table class="table table-sm mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Intake</th>
|
||||
<th>Date</th>
|
||||
<th>Received (kg)</th>
|
||||
<th>Allocated (kg)</th>
|
||||
<th>Origin Zone</th>
|
||||
<th>Collector Group</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in lineage %}
|
||||
<tr>
|
||||
<td><strong>{{ row.harvest_intake.intake_reference }}</strong></td>
|
||||
<td>{{ row.harvest_intake.intake_date|date:'d/m/Y'|default:'-' }}</td>
|
||||
<td>{{ row.harvest_intake.weight_kg|default:'-' }}</td>
|
||||
<td>{{ row.weight_allocated_kg|default:'-' }}</td>
|
||||
<td>
|
||||
{% if row.harvest_intake.origin_zone %}
|
||||
{{ row.harvest_intake.origin_zone.zone_code }}
|
||||
{% if row.harvest_intake.origin_zone.verification_status != 'verified' %}
|
||||
<span class="badge bg-warning text-dark">{{ row.harvest_intake.origin_zone.get_verification_status_display }}</span>
|
||||
{% endif %}
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
<td>{{ row.harvest_intake.collector_group.name|default:'-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr class="table-light">
|
||||
<td colspan="3" class="text-end text-secondary">Total allocated</td>
|
||||
<td><strong>{{ lineage_allocated }}</strong></td>
|
||||
<td colspan="2" class="text-secondary">batch weight {{ batch.weight_kg|default:'-' }} kg</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
{% if lineage_difference > 0 %}
|
||||
<p class="small text-secondary mt-2 mb-0">
|
||||
{{ lineage_difference }} kg of this batch is not traced back to a harvest intake.
|
||||
The platform shows the two figures side by side; it does not reconcile them.
|
||||
</p>
|
||||
{% elif lineage_difference < 0 %}
|
||||
<p class="small text-secondary mt-2 mb-0">
|
||||
The allocated weights are above the batch weight — the usual direction,
|
||||
since processing loses weight. The platform shows the two figures side by
|
||||
side; it does not reconcile them.
|
||||
</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted mb-0">
|
||||
No harvest intakes are linked to this batch. Until at least one is, this
|
||||
batch cannot be traced back to a collector group and an origin zone, and
|
||||
the due-diligence case for it will fail its lineage check.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">QC Results ({{ qc_results|length }})</h6>
|
||||
{% if qc_results %}
|
||||
<table class=table table-sm mb-0>
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Result</th><th>FFA%</th><th>Moisture%</th><th>Peroxide</th><th>Date</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in qc_results %}
|
||||
<tr>
|
||||
<td>{% if r.overall_pass %}<span class=badge bg-success>Pass</span>{% else %}<span class=badge bg-danger>Fail</span>{% endif %}</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>
|
||||
|
|
@ -48,19 +119,19 @@
|
|||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class=text-muted mb-0>No QC results</p>{% endif %}
|
||||
{% else %}<p class="text-muted mb-0">No QC results</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class=col-md-6>
|
||||
<div class=stat-card>
|
||||
<h6 class=mb-3>Shipments ({{ shipments|length }})</h6>
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">Shipments ({{ shipments|length }})</h6>
|
||||
{% if shipments %}
|
||||
<table class=table table-sm mb-0>
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Status</th><th>Vessel</th><th>ETD</th><th>ETA</th></tr></thead>
|
||||
<tbody>
|
||||
{% for s in shipments %}
|
||||
<tr>
|
||||
<td><span class=badge bg-secondary badge-status>{{ s.get_status_display }}</span></td>
|
||||
<td><span class="badge bg-secondary badge-status">{{ s.get_status_display }}</span></td>
|
||||
<td>{{ s.vessel_name|default:'-' }}</td>
|
||||
<td>{{ s.etd_nigeria|date:'d/m/Y'|default:'-' }}</td>
|
||||
<td>{{ s.eta_uk|date:'d/m/Y'|default:'-' }}</td>
|
||||
|
|
@ -68,41 +139,41 @@
|
|||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class=text-muted mb-0>No shipments</p>{% endif %}
|
||||
{% else %}<p class="text-muted mb-0">No shipments</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=row g-3>
|
||||
<div class=col-md-6>
|
||||
<div class=stat-card>
|
||||
<h6 class=mb-3>CAPA Records ({{ capa_records|length }})</h6>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">CAPA Records ({{ capa_records|length }})</h6>
|
||||
{% if capa_records %}
|
||||
<table class=table table-sm mb-0>
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Issue</th><th>Status</th><th>Due Date</th></tr></thead>
|
||||
<tbody>
|
||||
{% for c in capa_records %}
|
||||
<tr>
|
||||
<td>{{ c.issue_description|truncatewords:10 }}</td>
|
||||
<td><span class=badge bg-warning text-dark badge-status>{{ c.get_status_display }}</span></td>
|
||||
<td><span class="badge bg-warning text-dark badge-status">{{ c.get_status_display }}</span></td>
|
||||
<td>{{ c.due_date|date:'d/m/Y'|default:'-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}<p class=text-muted mb-0>No CAPA records</p>{% endif %}
|
||||
{% else %}<p class="text-muted mb-0">No CAPA records</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class=col-md-6>
|
||||
<div class=stat-card>
|
||||
<h6 class=mb-3>Documents ({{ documents|length }})</h6>
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">Documents ({{ documents|length }})</h6>
|
||||
{% if documents %}
|
||||
<ul class=list-unstyled mb-0>
|
||||
<ul class="list-unstyled mb-0">
|
||||
{% for d in documents %}
|
||||
<li class=border-bottom pb-1 mb-1><i class=bi bi-file-earmark></i> {{ d.file_name }} <span class=badge bg-light text-dark>{{ d.get_doc_type_display }}</span></li>
|
||||
<li class="border-bottom pb-1 mb-1"><a href="{% url 'ops:document_download' d.id %}" target="_blank" class="text-decoration-none"><i class="bi bi-file-earmark"></i> {{ d.file_name }}</a> <span class="badge bg-light text-dark">{{ d.get_doc_type_display }}</span></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}<p class=text-muted mb-0>No documents</p>{% endif %}
|
||||
{% else %}<p class="text-muted mb-0">No documents</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
92
templates/ops/batch_form.html
Normal file
92
templates/ops/batch_form.html
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}{{ page_title }} - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>{{ page_title }}</h2>
|
||||
<a href="{% url 'ops:batch_list' %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7">
|
||||
<div class="stat-card">
|
||||
<form method="post" novalidate id="batch-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
{% for e in form.non_field_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="{{ field.id_for_label }}">
|
||||
{{ field.label }}{% if field.field.required and not field.field.disabled %} <span class="text-danger">*</span>{% endif %}
|
||||
</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
|
||||
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit" class="btn btn-primary">{{ submit_label }}</button>
|
||||
<a href="{% url 'ops:batch_list' %}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3"><i class="bi bi-diagram-3"></i> What a batch is</h6>
|
||||
<p class="small mb-2">
|
||||
The batch is the spine of the story: QC results, the certificate of
|
||||
analysis, the due-diligence case, shipments and the buyer's view all hang
|
||||
off it. If the reference is wrong, everything downstream is wrong with it.
|
||||
</p>
|
||||
<p class="small mb-0">
|
||||
Creating a batch is only half of it. It also needs its <strong>harvest
|
||||
intakes</strong> allocated to it, or it cannot be traced back to a
|
||||
collector group and an origin zone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3"><i class="bi bi-shield-check"></i> Who sets the status</h6>
|
||||
<ul class="small mb-2 ps-3">
|
||||
<li><strong>Harvested, Processing, Ready to Ship, In Transit, At UK Port,
|
||||
Customs, Delivered</strong> — moved here, by you.</li>
|
||||
<li><strong>QC Fail</strong> — never offered here; set by the platform
|
||||
when a QC result is recorded (step 5), because a quality failure must not
|
||||
be something a person can type over.</li>
|
||||
<li><strong>QC Pass</strong> — appears only as the current value when
|
||||
editing a batch that has already passed, so saving alone leaves it alone
|
||||
and it can be moved on to the journey statuses.</li>
|
||||
</ul>
|
||||
<p class="small mb-0">
|
||||
A batch sitting on a failed result cannot be moved at all until a new
|
||||
result — a retest — is recorded against it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if not creating %}
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3"><i class="bi bi-basket"></i> Harvest intakes</h6>
|
||||
<p class="small mb-2">
|
||||
Which intakes went into this batch, and how much of each. Allocated from
|
||||
the intakes screen for the batch.
|
||||
</p>
|
||||
<a href="{% url 'ops:batch_lineage' form.instance.pk %}" class="btn btn-sm btn-outline-primary">Edit intake lineage</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3"><i class="bi bi-basket"></i> Harvest intakes</h6>
|
||||
<p class="small mb-2">
|
||||
Once the batch exists you will be taken straight to its harvest intakes,
|
||||
so you can allocate what went into it. A batch with no intake linked to it
|
||||
cannot be traced back to a collector group and an origin zone.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
114
templates/ops/batch_lineage.html
Normal file
114
templates/ops/batch_lineage.html
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Intake lineage - {{ batch.batch_reference }} - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if messages %}
|
||||
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Intake lineage: {{ batch.batch_reference }}</h2>
|
||||
<a href="{% url 'ops:batch_detail' batch.pk %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back to the batch</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<div class="stat-card">
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{{ formset.management_form }}
|
||||
|
||||
{% if formset.non_form_errors %}
|
||||
{% for e in formset.non_form_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<!-- table-layout: fixed, or the intake dropdown's intrinsic width (a native
|
||||
select sizes to its longest option) pushes the weight field and the
|
||||
Remove box off the card. -->
|
||||
<table class="table table-sm align-middle mb-3" style="table-layout: fixed;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Harvest intake</th>
|
||||
<th style="width: 10rem;">Allocated (kg)</th>
|
||||
<th style="width: 6rem;">Remove</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in formset.forms %}
|
||||
<tr>
|
||||
{% for hidden in f.hidden_fields %}{{ hidden }}{% endfor %}
|
||||
<td>
|
||||
{{ f.harvest_intake }}
|
||||
{% for e in f.harvest_intake.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{{ f.weight_allocated_kg }}
|
||||
{% for e in f.weight_allocated_kg.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</td>
|
||||
<td>
|
||||
{% if f.instance.pk %}{{ f.DELETE }}{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save lineage</button>
|
||||
<a href="{% url 'ops:batch_detail' batch.pk %}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3"><i class="bi bi-calculator"></i> Weights</h6>
|
||||
<table class="table table-sm mb-0">
|
||||
<tr><td class="text-secondary">Allocated to this batch</td><td>{{ total_allocated }} kg</td></tr>
|
||||
<tr><td class="text-secondary">Batch weight</td><td>{{ batch.weight_kg|default:'-' }} kg</td></tr>
|
||||
{% if difference is not None %}
|
||||
<tr>
|
||||
<td class="text-secondary">Difference</td>
|
||||
<td>{{ difference }} kg</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
<p class="small text-secondary mt-2 mb-0">
|
||||
The platform shows these two figures side by side; it does not reconcile
|
||||
them. A difference where the batch is heavier means part of the batch is
|
||||
not traced to an intake; allocations above the batch weight are the usual
|
||||
processing loss.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3"><i class="bi bi-shield-check"></i> What this feeds</h6>
|
||||
<ul class="small mb-2 ps-3">
|
||||
<li><strong>Lineage</strong> — the batch needs at least one intake</li>
|
||||
<li><strong>Geolocation</strong> — every linked intake must point at a
|
||||
<em>verified</em> origin zone</li>
|
||||
<li><strong>Intake data</strong> — every linked intake needs a date and
|
||||
a weight</li>
|
||||
</ul>
|
||||
<p class="small mb-0">
|
||||
This table is what the due-diligence case for {{ batch.batch_reference }}
|
||||
reads, and what a buyer or an auditor traces the batch back through.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3"><i class="bi bi-exclamation-triangle"></i> One intake, limited weight</h6>
|
||||
<p class="small mb-2">
|
||||
An intake received a finite weight of shea, and what is allocated out of it
|
||||
is counted across <strong>every</strong> batch. An allocation that would
|
||||
take more than the intake received is refused, whether the excess is over
|
||||
the intake's own weight or over what other batches have already taken.
|
||||
</p>
|
||||
<p class="small mb-0">
|
||||
Removing a row here deletes the link only — the intake record itself
|
||||
is untouched.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -2,41 +2,44 @@
|
|||
{% block title %}Batches - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Processing Batches</h2>
|
||||
<span class=text-secondary>{{ page_obj.paginator.count }} total</span>
|
||||
<div>
|
||||
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
|
||||
<a href="{% url 'ops:batch_create' %}" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> New batch</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=filter-bar>
|
||||
<form method=get class=row g-2 align-items-end>
|
||||
<div class=col-auto>
|
||||
<input type=text name=q class=form-control form-control-sm placeholder=Search reference... value={{ current_q }}>
|
||||
<div class="filter-bar">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search reference..." value="{{ current_q }}">
|
||||
</div>
|
||||
<div class=col-auto>
|
||||
<select name=status class=form-select form-select-sm>
|
||||
<option value=>All Statuses</option>
|
||||
<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>
|
||||
<option value="{{ val }}" {% if current_status == val %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class=col-auto>
|
||||
<select name=grade class=form-select form-select-sm>
|
||||
<option value=>All Grades</option>
|
||||
<div class="col-auto">
|
||||
<select name="grade" class="form-select form-select-sm">
|
||||
<option value="">All Grades</option>
|
||||
{% for val, label in grade_choices %}
|
||||
<option value={{ val }} {% if current_grade == val %}selected{% endif %}>{{ label }}</option>
|
||||
<option value="{{ val }}" {% if current_grade == 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:batch_list' %} class=btn btn-sm btn-outline-secondary>Clear</a>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
<a href="{% url 'ops:batch_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>
|
||||
<div class="stat-card p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Reference</th>
|
||||
|
|
@ -54,29 +57,29 @@
|
|||
<td><strong>{{ b.batch_reference }}</strong></td>
|
||||
<td>{{ b.grade|default:'-' }}</td>
|
||||
<td>{{ b.weight_kg|default:'-' }}</td>
|
||||
<td><span class=badge bg-secondary badge-status>{{ b.get_status_display }}</span></td>
|
||||
<td><span class="badge bg-secondary badge-status">{{ b.get_status_display }}</span></td>
|
||||
<td>{{ b.harvest_date|date:'d/m/Y'|default:'-' }}</td>
|
||||
<td>{{ b.created_at|date:'d/m/Y' }}</td>
|
||||
<td><a href={% url 'ops:batch_detail' b.pk %} class=btn btn-sm btn-outline-primary>View</a></td>
|
||||
<td><a href="{% url 'ops:batch_detail' b.pk %}" class="btn btn-sm btn-outline-primary">View</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan=7 class=text-muted text-center py-3>No batches found</td></tr>
|
||||
<tr><td colspan="7" class="text-muted text-center py-3">No batches found</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>
|
||||
<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>«</a></li>
|
||||
<li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>
|
||||
<li class="page-item"><a class="page-link" href="?page=1">«</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>
|
||||
<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 }}>»</a></li>
|
||||
<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 }}>»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -2,45 +2,45 @@
|
|||
{% block title %}CAPA Detail - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>CAPA Record: {{ record.batch.batch_reference }}</h2>
|
||||
<a href={% url 'ops:capa_list' %} class=btn btn-sm btn-outline-secondary><i class=bi bi-arrow-left></i> Back</a>
|
||||
<a href="{% url 'ops:capa_list' %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
|
||||
</div>
|
||||
|
||||
<div class=row g-3>
|
||||
<div class=col-md-8>
|
||||
<div class=stat-card mb-3>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<div class="stat-card mb-3">
|
||||
<h6>Issue Description</h6>
|
||||
<p>{{ record.issue_description|linebreaks }}</p>
|
||||
</div>
|
||||
{% if record.root_cause %}
|
||||
<div class=stat-card mb-3>
|
||||
<div class="stat-card mb-3">
|
||||
<h6>Root Cause</h6>
|
||||
<p>{{ record.root_cause|linebreaks }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if record.corrective_action %}
|
||||
<div class=stat-card mb-3>
|
||||
<div class="stat-card mb-3">
|
||||
<h6>Corrective Action</h6>
|
||||
<p>{{ record.corrective_action|linebreaks }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if record.preventive_action %}
|
||||
<div class=stat-card>
|
||||
<div class="stat-card">
|
||||
<h6>Preventive Action</h6>
|
||||
<p>{{ record.preventive_action|linebreaks }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class=col-md-4>
|
||||
<div class=stat-card>
|
||||
<table class=table table-sm table-borderless mb-0>
|
||||
<tr><td class=text-secondary>Batch</td><td><a href={% url 'ops:batch_detail' record.batch.pk %}>{{ record.batch.batch_reference }}</a></td></tr>
|
||||
<tr><td class=text-secondary>Status</td><td><span class=badge {% if record.status == open %}bg-danger{% elif record.status == in_progress %}bg-warning text-dark{% else %}bg-success{% endif %} badge-status>{{ record.get_status_display }}</span></td></tr>
|
||||
<tr><td class=text-secondary>Assigned To</td><td>{{ record.assigned_to|default:'-' }}</td></tr>
|
||||
<tr><td class=text-secondary>Due Date</td><td>{{ record.due_date|date:'d/m/Y'|default:'-' }}</td></tr>
|
||||
<tr><td class=text-secondary>Created</td><td>{{ record.created_at|date:'d/m/Y H:i' }}</td></tr>
|
||||
{% if record.closed_at %}<tr><td class=text-secondary>Closed</td><td>{{ record.closed_at|date:'d/m/Y H:i' }}</td></tr>{% endif %}
|
||||
<div class="col-md-4">
|
||||
<div class="stat-card">
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tr><td class="text-secondary">Batch</td><td><a href="{% url 'ops:batch_detail' record.batch.pk %}">{{ record.batch.batch_reference }}</a></td></tr>
|
||||
<tr><td class="text-secondary">Status</td><td><span class="badge {% if record.status == open %}bg-danger{% elif record.status == in_progress %}bg-warning text-dark{% else %}bg-success{% endif %} badge-status">{{ record.get_status_display }}</span></td></tr>
|
||||
<tr><td class="text-secondary">Assigned To</td><td>{{ record.assigned_to|default:'-' }}</td></tr>
|
||||
<tr><td class="text-secondary">Due Date</td><td>{{ record.due_date|date:'d/m/Y'|default:'-' }}</td></tr>
|
||||
<tr><td class="text-secondary">Created</td><td>{{ record.created_at|date:'d/m/Y H:i' }}</td></tr>
|
||||
{% if record.closed_at %}<tr><td class="text-secondary">Closed</td><td>{{ record.closed_at|date:'d/m/Y H:i' }}</td></tr>{% endif %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,30 +2,30 @@
|
|||
{% block title %}CAPA Records - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>CAPA Records</h2>
|
||||
<span class=text-secondary>{{ page_obj.paginator.count }} total</span>
|
||||
<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=>All Statuses</option>
|
||||
<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>
|
||||
<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:capa_list' %} class=btn btn-sm btn-outline-secondary>Clear</a>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
<a href="{% url 'ops:capa_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>
|
||||
<div class="stat-card p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr><th>Batch</th><th>Issue</th><th>Status</th><th>Assigned To</th><th>Due Date</th><th>Created</th><th></th></tr>
|
||||
</thead>
|
||||
|
|
@ -34,24 +34,24 @@
|
|||
<tr>
|
||||
<td><strong>{{ r.batch.batch_reference }}</strong></td>
|
||||
<td>{{ r.issue_description|truncatewords:15 }}</td>
|
||||
<td><span class=badge {% if r.status == open %}bg-danger{% elif r.status == in_progress %}bg-warning text-dark{% else %}bg-success{% endif %} badge-status>{{ r.get_status_display }}</span></td>
|
||||
<td><span class="badge {% if r.status == open %}bg-danger{% elif r.status == in_progress %}bg-warning text-dark{% else %}bg-success{% endif %} badge-status">{{ r.get_status_display }}</span></td>
|
||||
<td>{{ r.assigned_to|default:'-' }}</td>
|
||||
<td>{{ r.due_date|date:'d/m/Y'|default:'-' }}</td>
|
||||
<td>{{ r.created_at|date:'d/m/Y' }}</td>
|
||||
<td><a href={% url 'ops:capa_detail' r.pk %} class=btn btn-sm btn-outline-primary>View</a></td>
|
||||
<td><a href="{% url 'ops:capa_detail' r.pk %}" class="btn btn-sm btn-outline-primary">View</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan=7 class=text-muted text-center py-3>No CAPA records</td></tr>
|
||||
<tr><td colspan="7" class="text-muted text-center py-3">No CAPA 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>«</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 }}>»</a></li>{% endif %}
|
||||
<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">«</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 }}>»</a></li>{% endif %}
|
||||
</ul></nav>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,61 +2,61 @@
|
|||
{% block title %}CoA Review - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<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>
|
||||
<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>
|
||||
<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>{% if coa.quality_approved_by %}{{ coa.quality_approved_by.full_name|default:coa.quality_approved_by.email }}{% else %}-{% endif %}</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="{% url 'ops:coa_download' coa.id %}" 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>
|
||||
<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>
|
||||
<a href="{% url 'ops:document_download' coa.source_document.id %}" 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>
|
||||
<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 %}
|
||||
<p class="small text-muted mb-1">All lab documents for batch:</p>
|
||||
{% for d in lab_documents %}<div><a href="{% url 'ops:document_download' d.id %}" 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>
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">Commercial Release</h6>
|
||||
{% if coa.status == 'pending_approval' %}
|
||||
<form method=post>
|
||||
<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 & Publish</button>
|
||||
<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 & Publish</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class=text-muted mb-0">Commercial release approved. Buyer document and notification created.</p>
|
||||
<p class="text-muted mb-0">Commercial release approved. Buyer document and notification created.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,30 +2,30 @@
|
|||
{% block title %}CoA Reviews - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
<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>
|
||||
|
|
@ -34,24 +34,24 @@
|
|||
<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><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>
|
||||
<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>
|
||||
<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>«</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 }}>»</a></li>{% endif %}
|
||||
<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">«</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 }}>»</a></li>{% endif %}
|
||||
</ul></nav>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
79
templates/ops/collector_group_list.html
Normal file
79
templates/ops/collector_group_list.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Collector Groups - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if messages %}
|
||||
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Collector Groups</h2>
|
||||
<div>
|
||||
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
|
||||
<a href="{% url 'ops:collector_group_create' %}" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> New collector group</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search name..." value="{{ current_q }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
<a href="{% url 'ops:collector_group_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>Name</th>
|
||||
<th>Contact reference</th>
|
||||
<th>Origin zone</th>
|
||||
<th>Notes</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for g in groups %}
|
||||
<tr>
|
||||
<td><strong>{{ g.name }}</strong></td>
|
||||
<td>{{ g.contact_reference|default:'-' }}</td>
|
||||
<td>
|
||||
{% if g.origin_zone %}
|
||||
{{ g.origin_zone.zone_code }}
|
||||
{% if g.origin_zone.verification_status != 'verified' %}
|
||||
<span class="badge bg-warning text-dark">{{ g.origin_zone.get_verification_status_display }}</span>
|
||||
{% endif %}
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
<td class="text-truncate" style="max-width:280px;">{{ g.notes|default:'-' }}</td>
|
||||
<td><a href="{% url 'ops:collector_group_update' g.pk %}" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="text-muted text-center py-3">No collector groups registered yet</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">«</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 }}">»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -2,69 +2,69 @@
|
|||
{% block title %}Dashboard - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Operations Dashboard</h2>
|
||||
<span class=text-secondary>Total Batches: <strong>{{ total_batches }}</strong></span>
|
||||
<span class="text-secondary">Total Batches: <strong>{{ total_batches }}</strong></span>
|
||||
</div>
|
||||
|
||||
<div class=row g-3 mb-4>
|
||||
<div class=col-md-3>
|
||||
<div class=stat-card>
|
||||
<div class=stat-value text-success>{{ qc_summary.passed }}</div>
|
||||
<div class=stat-label>QC Passed</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-success">{{ qc_summary.passed }}</div>
|
||||
<div class="stat-label">QC Passed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=col-md-3>
|
||||
<div class=stat-card>
|
||||
<div class=stat-value text-danger>{{ qc_summary.failed }}</div>
|
||||
<div class=stat-label>QC Failed</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-danger">{{ qc_summary.failed }}</div>
|
||||
<div class="stat-label">QC Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=col-md-3>
|
||||
<div class=stat-card>
|
||||
<div class=stat-value text-primary>{{ active_shipments }}</div>
|
||||
<div class=stat-label>Active Shipments</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-primary">{{ active_shipments }}</div>
|
||||
<div class="stat-label">Active Shipments</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class=col-md-3>
|
||||
<div class=stat-card>
|
||||
<div class=stat-value text-warning>{{ open_capas }}</div>
|
||||
<div class=stat-label>Open CAPAs</div>
|
||||
<div class="col-md-3">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value text-warning">{{ open_capas }}</div>
|
||||
<div class="stat-label">Open CAPAs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=row g-3 mb-4>
|
||||
<div class=col-md-6>
|
||||
<div class=stat-card>
|
||||
<h6 class=mb-3>Batches by Status</h6>
|
||||
<table class=table table-sm table-borderless mb-0>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">Batches by Status</h6>
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
{% for label, count in batches_by_status.items %}
|
||||
<tr>
|
||||
<td><span class=badge bg-secondary badge-status>{{ label }}</span></td>
|
||||
<td class=text-end><strong>{{ count }}</strong></td>
|
||||
<td><span class="badge bg-secondary badge-status">{{ label }}</span></td>
|
||||
<td class="text-end"><strong>{{ count }}</strong></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td class=text-muted>No batches yet</td></tr>
|
||||
<tr><td class="text-muted">No batches yet</td></tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class=col-md-6>
|
||||
<div class=stat-card>
|
||||
<h6 class=mb-3>Recent Batches</h6>
|
||||
<table class=table table-sm mb-0>
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">Recent Batches</h6>
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Reference</th><th>Status</th><th>Grade</th><th>Created</th></tr></thead>
|
||||
<tbody>
|
||||
{% for b in recent_batches %}
|
||||
<tr>
|
||||
<td><a href={% url 'ops:batch_detail' b.pk %}>{{ b.batch_reference }}</a></td>
|
||||
<td><span class=badge bg-secondary badge-status>{{ b.get_status_display }}</span></td>
|
||||
<td><a href="{% url 'ops:batch_detail' b.pk %}">{{ b.batch_reference }}</a></td>
|
||||
<td><span class="badge bg-secondary badge-status">{{ b.get_status_display }}</span></td>
|
||||
<td>{{ b.grade|default:'-' }}</td>
|
||||
<td>{{ b.created_at|date:'d/m/Y' }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan=4 class=text-muted>No batches yet</td></tr>
|
||||
<tr><td colspan="4" class="text-muted">No batches yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
|
@ -72,37 +72,37 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class=row g-3>
|
||||
<div class=col-md-6>
|
||||
<div class=stat-card>
|
||||
<h6 class=mb-3>Recent QC Results</h6>
|
||||
<table class=table table-sm mb-0>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">Recent QC Results</h6>
|
||||
<table class="table table-sm mb-0">
|
||||
<thead><tr><th>Batch</th><th>Result</th><th>FFA%</th><th>Date</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in recent_qc %}
|
||||
<tr>
|
||||
<td><a href={% url 'ops:qc_detail' r.pk %}>{{ r.batch.batch_reference }}</a></td>
|
||||
<td>{% if r.overall_pass %}<span class=badge bg-success>Pass</span>{% else %}<span class=badge bg-danger>Fail</span>{% endif %}</td>
|
||||
<td><a href="{% url 'ops:qc_detail' r.pk %}">{{ r.batch.batch_reference }}</a></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.tested_at|date:'d/m/Y' }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan=4 class=text-muted>No QC results yet</td></tr>
|
||||
<tr><td colspan="4" class="text-muted">No QC results yet</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class=col-md-6>
|
||||
<div class=stat-card>
|
||||
<h6 class=mb-3>Unread Notifications</h6>
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">Unread Notifications</h6>
|
||||
{% for n in recent_notifications %}
|
||||
<div class=border-bottom pb-2 mb-2>
|
||||
<div class="border-bottom pb-2 mb-2">
|
||||
<strong>{{ n.title }}</strong>
|
||||
<p class=mb-0 text-muted small>{{ n.message|truncatewords:20 }}</p>
|
||||
<p class="mb-0 text-muted small">{{ n.message|truncatewords:20 }}</p>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class=text-muted mb-0>No unread notifications</p>
|
||||
<p class="text-muted mb-0">No unread notifications</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,55 +2,55 @@
|
|||
{% block title %}Documents - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Documents</h2>
|
||||
<span class=text-secondary>{{ page_obj.paginator.count }} total</span>
|
||||
<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=doc_type class=form-select form-select-sm>
|
||||
<option value=>All Types</option>
|
||||
<div class="filter-bar">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<select name="doc_type" class="form-select form-select-sm">
|
||||
<option value="">All Types</option>
|
||||
{% for val, label in doc_type_choices %}
|
||||
<option value={{ val }} {% if current_doc_type == val %}selected{% endif %}>{{ label }}</option>
|
||||
<option value="{{ val }}" {% if current_doc_type == 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:document_list' %} class=btn btn-sm btn-outline-secondary>Clear</a>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
<a href="{% url 'ops:document_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>
|
||||
<div class="stat-card p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr><th>File Name</th><th>Type</th><th>Batch</th><th>Visibility</th><th>Uploaded By</th><th>Uploaded</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for d in documents %}
|
||||
<tr>
|
||||
<td><i class=bi bi-file-earmark></i> {{ d.file_name }}</td>
|
||||
<td><span class=badge bg-light text-dark>{{ d.get_doc_type_display }}</span></td>
|
||||
<td><a href="{% url 'ops:document_download' d.id %}" target="_blank" class="text-decoration-none"><i class="bi bi-file-earmark"></i> {{ d.file_name }}</a></td>
|
||||
<td><span class="badge bg-light text-dark">{{ d.get_doc_type_display }}</span></td>
|
||||
<td>{{ d.batch.batch_reference|default:'-' }}</td>
|
||||
<td><span class=badge bg-info text-dark>{{ d.get_visibility_display }}</span></td>
|
||||
<td>{{ d.uploaded_by.full_name|default:d.uploaded_by.email|default:'-' }}</td>
|
||||
<td><span class="badge bg-info text-dark">{{ d.get_visibility_display }}</span></td>
|
||||
<td>{% if d.uploaded_by %}{{ d.uploaded_by.full_name|default:d.uploaded_by.email }}{% else %}-{% endif %}</td>
|
||||
<td>{{ d.uploaded_at|date:'d/m/Y' }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan=6 class=text-muted text-center py-3>No documents</td></tr>
|
||||
<tr><td colspan="6" class="text-muted text-center py-3">No documents</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>«</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 }}>»</a></li>{% endif %}
|
||||
<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">«</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 }}>»</a></li>{% endif %}
|
||||
</ul></nav>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@
|
|||
{% block title %}ESG Reports - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>ESG Field Reports</h2>
|
||||
<span class=text-secondary>{{ page_obj.paginator.count }} total</span>
|
||||
<span class="text-secondary">{{ page_obj.paginator.count }} total</span>
|
||||
</div>
|
||||
|
||||
<div class=stat-card p-0>
|
||||
<table class=table table-hover mb-0>
|
||||
<div class="stat-card p-0">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr><th>Batch</th><th>Women Employed</th><th>Total Workers</th><th>Fair Wage</th><th>Safety Equipment</th><th>Report Date</th><th>Reported By</th></tr>
|
||||
</thead>
|
||||
|
|
@ -18,23 +18,23 @@
|
|||
<td><strong>{{ r.batch.batch_reference }}</strong></td>
|
||||
<td>{{ r.women_employed }}</td>
|
||||
<td>{{ r.total_workers }}</td>
|
||||
<td>{% if r.fair_wage_paid %}<span class=badge bg-success>Yes</span>{% else %}<span class=badge bg-danger>No</span>{% endif %}</td>
|
||||
<td>{% if r.safety_equipment_used %}<span class=badge bg-success>Yes</span>{% else %}<span class=badge bg-danger>No</span>{% endif %}</td>
|
||||
<td>{% if r.fair_wage_paid %}<span class="badge bg-success">Yes</span>{% else %}<span class="badge bg-danger">No</span>{% endif %}</td>
|
||||
<td>{% if r.safety_equipment_used %}<span class="badge bg-success">Yes</span>{% else %}<span class="badge bg-danger">No</span>{% endif %}</td>
|
||||
<td>{{ r.report_date|date:'d/m/Y'|default:'-' }}</td>
|
||||
<td>{{ r.reported_by|default:'-' }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan=7 class=text-muted text-center py-3>No ESG reports</td></tr>
|
||||
<tr><td colspan="7" class="text-muted text-center py-3">No ESG reports</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>«</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 }}>»</a></li>{% endif %}
|
||||
<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">«</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 }}>»</a></li>{% endif %}
|
||||
</ul></nav>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,137 +2,158 @@
|
|||
{% block title %}EUDR Case - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>{% 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>
|
||||
<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>
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3">Intake Lineage</h6>
|
||||
{% if lineage %}
|
||||
<table class=table table-sm mb-0>
|
||||
<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>{% if link.weight_allocated_kg %}{{ link.weight_allocated_kg }}{% elif link.harvest_intake %}{{ link.harvest_intake.weight_kg }}{% else %}-{% endif %}</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>{% 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>
|
||||
<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>
|
||||
<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>
|
||||
<div class="stat-card mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">EUDR Evidence</h6>
|
||||
<a href="{% url 'ops:eudr_evidence_create' case.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-paperclip"></i> Attach evidence</a>
|
||||
</div>
|
||||
{% for e in evidence %}
|
||||
<div class="border-bottom pb-2 mb-2">
|
||||
<span class="badge bg-secondary">{{ e.get_evidence_type_display }}</span>
|
||||
<span class="small">{{ e.file }} <span class="text-muted">{% if e.source %}· {{ e.source }}{% endif %}{% if e.collected_at %} · {{ e.collected_at|date:'d/m/Y' }}{% endif %}</span></span>
|
||||
<a href="{% url 'ops:eudr_evidence_update' case.pk e.pk %}" class="btn btn-sm btn-link p-0 ms-2">Edit</a>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted mb-0">No evidence attached. The completeness check needs geolocation, harvest document and supplier identity.</p>
|
||||
{% endfor %}
|
||||
</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 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>
|
||||
<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>
|
||||
<div class="stat-card mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Mitigation Actions</h6>
|
||||
<a href="{% url 'ops:eudr_mitigation_create' case.pk %}" class="btn btn-sm btn-outline-primary"><i class="bi bi-plus-lg"></i> Add action</a>
|
||||
</div>
|
||||
{% 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 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>
|
||||
<a href="{% url 'ops:eudr_mitigation_update' case.pk m.pk %}" class="btn btn-sm btn-link p-0 ms-2">Edit</a>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class=text-muted mb-0>No mitigation actions.</p>
|
||||
<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>
|
||||
<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 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>
|
||||
<a href="{% url 'ops:eudr_statement_update' case.pk s.pk %}" class="btn btn-sm btn-link p-0 ms-2">Edit</a>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class=text-muted mb-0>No external submission recorded. Submissions are manual.</p>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -2,35 +2,35 @@
|
|||
{% block title %}EUDR Compliance - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<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>
|
||||
<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
|
||||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
<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>
|
||||
|
|
@ -38,29 +38,24 @@
|
|||
{% 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><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>
|
||||
<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>
|
||||
<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>«</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 }}>»</a></li>{% endif %}
|
||||
<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">«</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 }}>»</a></li>{% endif %}
|
||||
</ul></nav>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
81
templates/ops/facility_list.html
Normal file
81
templates/ops/facility_list.html
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Facilities - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if messages %}
|
||||
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Facilities</h2>
|
||||
<div>
|
||||
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
|
||||
<a href="{% url 'ops:facility_create' %}" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> New facility</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search name..." value="{{ current_q }}">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select name="active" class="form-select form-select-sm">
|
||||
<option value="">All</option>
|
||||
<option value="1" {% if current_active == '1' %}selected{% endif %}>Active</option>
|
||||
<option value="0" {% if current_active == '0' %}selected{% endif %}>Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
<a href="{% url 'ops:facility_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>Name</th>
|
||||
<th>Country</th>
|
||||
<th>Region</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in facilities %}
|
||||
<tr>
|
||||
<td><strong>{{ f.name }}</strong></td>
|
||||
<td>{{ f.country }}</td>
|
||||
<td>{{ f.region|default:'-' }}</td>
|
||||
<td>{{ f.get_facility_type_display }}</td>
|
||||
<td>{% if f.is_active %}<span class="badge bg-success">Active</span>{% else %}<span class="badge bg-secondary">Retired</span>{% endif %}</td>
|
||||
<td><a href="{% url 'ops:facility_update' f.pk %}" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-muted text-center py-3">No facilities registered yet</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">«</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 }}">»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
71
templates/ops/intake_form.html
Normal file
71
templates/ops/intake_form.html
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% load static %}
|
||||
{% block title %}{{ page_title }} - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>{{ page_title }}</h2>
|
||||
<a href="{% url 'ops:intake_list' %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-7">
|
||||
<div class="stat-card">
|
||||
<form method="post" novalidate id="intake-form" data-group-zones="{{ group_zones }}">
|
||||
{% csrf_token %}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
{% for e in form.non_field_errors %}<div class="alert alert-danger py-2">{{ e }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="{{ field.id_for_label }}">
|
||||
{{ field.label }}{% if field.field.required %} <span class="text-danger">*</span>{% endif %}
|
||||
</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<div class="form-text">{{ field.help_text }}</div>{% endif %}
|
||||
{% for e in field.errors %}<div class="text-danger small">{{ e }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit" class="btn btn-primary">{{ submit_label }}</button>
|
||||
<a href="{% url 'ops:intake_list' %}" class="btn btn-outline-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-5">
|
||||
<div class="stat-card mb-3">
|
||||
<h6 class="mb-3"><i class="bi bi-info-circle"></i> Recording an intake</h6>
|
||||
<p class="small mb-2">
|
||||
An intake is the moment a collector group hands over shea at the processing
|
||||
site. It is the record a finished batch is traced back to, so the fields
|
||||
marked <span class="text-danger">*</span> are required: they are the ones the
|
||||
platform's due-diligence checks read.
|
||||
</p>
|
||||
<p class="small mb-0">
|
||||
If something is wrong on a saved intake, use <strong>Correct</strong> on the
|
||||
list. Nothing is deleted — the record is amended, so the reference a buyer
|
||||
or an auditor sees stays the same.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3"><i class="bi bi-shield-check"></i> What these records feed</h6>
|
||||
<ul class="small mb-0 ps-3">
|
||||
<li><strong>Lineage</strong> — a batch needs at least one intake linked to it</li>
|
||||
<li><strong>Intake data</strong> — a linked intake must have both a date and a weight</li>
|
||||
<li><strong>Geolocation</strong> — every linked intake must point at a <em>verified</em> origin zone</li>
|
||||
</ul>
|
||||
<p class="small text-secondary mt-2 mb-0">
|
||||
A missing date, weight or zone holds the batch's EUDR case back until it is
|
||||
corrected here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{% static 'js/ixg_intake_form.js' %}"></script>
|
||||
|
||||
{% endblock %}
|
||||
93
templates/ops/intake_list.html
Normal file
93
templates/ops/intake_list.html
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Harvest Intakes - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if messages %}
|
||||
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Harvest Intakes</h2>
|
||||
<div>
|
||||
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
|
||||
<a href="{% url 'ops:intake_create' %}" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> New intake</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search reference..." value="{{ current_q }}">
|
||||
</div>
|
||||
<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:intake_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>Reference</th>
|
||||
<th>Intake Date</th>
|
||||
<th>Weight (kg)</th>
|
||||
<th>Grade</th>
|
||||
<th>Collector Group</th>
|
||||
<th>Origin Zone</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for i in intakes %}
|
||||
<tr>
|
||||
<td><strong>{{ i.intake_reference }}</strong></td>
|
||||
<td>{{ i.intake_date|date:'d/m/Y'|default:'-' }}</td>
|
||||
<td>{{ i.weight_kg|default:'-' }}</td>
|
||||
<td>{{ i.grade|default:'-' }}</td>
|
||||
<td>{{ i.collector_group.name|default:'-' }}</td>
|
||||
<td>
|
||||
{% if i.origin_zone %}
|
||||
{{ i.origin_zone.zone_code }}
|
||||
{% if i.origin_zone.verification_status != 'verified' %}
|
||||
<span class="badge bg-warning text-dark">{{ i.origin_zone.get_verification_status_display }}</span>
|
||||
{% endif %}
|
||||
{% else %}-{% endif %}
|
||||
</td>
|
||||
<td><span class="badge bg-secondary badge-status">{{ i.get_status_display }}</span></td>
|
||||
<td><a href="{% url 'ops:intake_update' i.pk %}" class="btn btn-sm btn-outline-primary">Correct</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="8" class="text-muted text-center py-3">No intakes recorded yet</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">«</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 }}>»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
90
templates/ops/origin_zone_list.html
Normal file
90
templates/ops/origin_zone_list.html
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
{% extends 'ops/base_ops.html' %}
|
||||
{% block title %}Origin Zones - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
{% if messages %}
|
||||
{% for m in messages %}<div class="alert alert-{{ m.tags }} py-2">{{ m }}</div>{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>Origin Zones</h2>
|
||||
<div>
|
||||
<span class="text-secondary me-2">{{ page_obj.paginator.count }} total</span>
|
||||
<a href="{% url 'ops:origin_zone_create' %}" class="btn btn-sm btn-primary"><i class="bi bi-plus-lg"></i> New origin zone</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-auto">
|
||||
<input type="text" name="q" class="form-control form-control-sm" placeholder="Search zone code..." value="{{ current_q }}">
|
||||
</div>
|
||||
<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:origin_zone_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>Zone code</th>
|
||||
<th>Facility</th>
|
||||
<th>Country</th>
|
||||
<th>Region</th>
|
||||
<th>Verification</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for z in zones %}
|
||||
<tr>
|
||||
<td><strong>{{ z.zone_code }}</strong></td>
|
||||
<td>{{ z.facility.name|default:'-' }}</td>
|
||||
<td>{{ z.country }}</td>
|
||||
<td>{{ z.region|default:'-' }}</td>
|
||||
<td>
|
||||
{% if z.verification_status == 'verified' %}
|
||||
<span class="badge bg-success">{{ z.get_verification_status_display }}</span>
|
||||
{% elif z.verification_status == 'rejected' %}
|
||||
<span class="badge bg-danger">{{ z.get_verification_status_display }}</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">{{ z.get_verification_status_display }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a href="{% url 'ops:origin_zone_update' z.pk %}" class="btn btn-sm btn-outline-primary">Edit</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="6" class="text-muted text-center py-3">No origin zones registered yet</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">«</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 }}">»</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
|
@ -2,24 +2,24 @@
|
|||
{% block title %}QC Detail - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>QC Result: {{ result.batch.batch_reference }}</h2>
|
||||
<a href={% url 'ops:qc_list' %} class=btn btn-sm btn-outline-secondary><i class=bi bi-arrow-left></i> Back</a>
|
||||
<a href="{% url 'ops:qc_list' %}" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i> Back</a>
|
||||
</div>
|
||||
|
||||
<div class=row g-3>
|
||||
<div class=col-md-6>
|
||||
<div class=stat-card>
|
||||
<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>FFA %</td><td>{{ result.ffa_percentage }}</td></tr>
|
||||
<tr><td class=text-secondary>Moisture %</td><td>{{ result.moisture_percentage }}</td></tr>
|
||||
<tr><td class=text-secondary>Peroxide Value</td><td>{{ result.peroxide_value }}</td></tr>
|
||||
<tr><td class=text-secondary>Visual Grade</td><td>{{ result.visual_grade }}</td></tr>
|
||||
<tr><td class=text-secondary>Tested By</td><td>{{ result.tested_by|default:'-' }}</td></tr>
|
||||
<tr><td class=text-secondary>Tested At</td><td>{{ result.tested_at|date:'d/m/Y H:i' }}</td></tr>
|
||||
<tr><td class=text-secondary>Lab Reference</td><td>{{ result.lab_reference|default:'-' }}</td></tr>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<div class="stat-card">
|
||||
<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">FFA %</td><td>{{ result.ffa_percentage }}</td></tr>
|
||||
<tr><td class="text-secondary">Moisture %</td><td>{{ result.moisture_percentage }}</td></tr>
|
||||
<tr><td class="text-secondary">Peroxide Value</td><td>{{ result.peroxide_value }}</td></tr>
|
||||
<tr><td class="text-secondary">Visual Grade</td><td>{{ result.visual_grade }}</td></tr>
|
||||
<tr><td class="text-secondary">Tested By</td><td>{{ result.tested_by|default:'-' }}</td></tr>
|
||||
<tr><td class="text-secondary">Tested At</td><td>{{ result.tested_at|date:'d/m/Y H:i' }}</td></tr>
|
||||
<tr><td class="text-secondary">Lab Reference</td><td>{{ result.lab_reference|default:'-' }}</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -2,29 +2,29 @@
|
|||
{% block title %}QC Results - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<div class="page-header d-flex justify-content-between align-items-center">
|
||||
<h2>QC Results</h2>
|
||||
<span class=text-secondary>{{ page_obj.paginator.count }} total</span>
|
||||
<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=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>
|
||||
<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_list' %} class=btn btn-sm btn-outline-secondary>Clear</a>
|
||||
<div class="col-auto">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
<a href="{% url 'ops:qc_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>
|
||||
<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>Tested</th><th></th></tr>
|
||||
</thead>
|
||||
|
|
@ -32,26 +32,26 @@
|
|||
{% 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>{% 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.tested_at|date:'d/m/Y' }}</td>
|
||||
<td><a href={% url 'ops:qc_detail' r.pk %} class=btn btn-sm btn-outline-primary>View</a></td>
|
||||
<td><a href="{% url 'ops:qc_detail' r.pk %}" class="btn btn-sm btn-outline-primary">View</a></td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan=8 class=text-muted text-center py-3>No QC results</td></tr>
|
||||
<tr><td colspan="8" class="text-muted text-center py-3">No QC results</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>«</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 }}>»</a></li>{% endif %}
|
||||
<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">«</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 }}>»</a></li>{% endif %}
|
||||
</ul></nav>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,48 +2,48 @@
|
|||
{% block title %}QC Verification - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<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>
|
||||
<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>
|
||||
<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>{% if result.entered_by %}{{ result.entered_by.full_name|default:result.entered_by.email }}{% else %}-{% endif %}</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>
|
||||
<div class="col-md-5">
|
||||
<div class="stat-card">
|
||||
<h6 class="mb-3">Approval Decision</h6>
|
||||
{% if result.verification_status == 'draft' %}
|
||||
<form method=post>
|
||||
<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 & 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>
|
||||
<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 & 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 %}
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -2,29 +2,29 @@
|
|||
{% block title %}QC Approvals - IXG Operations{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class=page-header d-flex justify-content-between align-items-center>
|
||||
<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>
|
||||
<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>
|
||||
<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 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>
|
||||
<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>
|
||||
|
|
@ -32,27 +32,27 @@
|
|||
{% 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>{% 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>{% if r.entered_by %}{{ r.entered_by.full_name|default:r.entered_by.email }}{% else %}-{% endif %}</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>
|
||||
<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>
|
||||
<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>«</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 }}>»</a></li>{% endif %}
|
||||
<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">«</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 }}>»</a></li>{% endif %}
|
||||
</ul></nav>
|
||||
{% endif %}
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue