Compare commits

...

30 commits

Author SHA1 Message Date
ef6f217e45 Gate the administration area by role, not by is_staff
RBACMiddleware only guarded non-staff users, so any staff account stepped
past the role routing for /admin/ — an ops or buyer account with the flag
set could reach the admin index, which embeds the model registry, and a
later permission grant would have been inherited. Restrict /admin/ to the
admin role (superuser excepted) and hide the ops Admin link from non-admins.

Also close the second finding: the seed set is_staff only on creation, so
a buyer whose flag had drifted stayed staff on every re-run. Derive it from
the role on the update path too, and align existing rows with a one-off
migration so the flag never disagrees with the role.
2026-09-25 16:47:50 +00:00
df2a90bc71 Add the product specification screen to the ops portal
Operations can now create and edit a specification and its parameters
together, over the existing model, mirroring the admin's inline.

The approval half is deliberately held: the form does not expose status,
is_current, approved_by or approved_at, so a portal-created specification
is a draft and cannot be approved here. The edit screen warns when the
record is not a draft, because its parameters are the limits QC results
are judged against. Nothing is removed from the admin.

The parameter formset refuses an empty specification (with no parameters
every QC result would pass), a numeric parameter with no limits, and a
visual parameter with no allowed values.
2026-09-25 16:35:45 +00:00
f6aedc0002 Add the compliance registers to the ops portal
Facility, origin zone and collector group could only be registered in the
Django admin, which left the portal unable to onboard a new supply site,
zone or group. Add list/create/edit screens for all three, and create/edit
for mitigation actions, EUDR evidence and external-submission references
on a due-diligence case.

The origin-zone form keeps the map a view of the stored geometry, not a
check of it: the map wording is unchanged and geometry is only required to
be valid JSON. Verification is stamped from the acting user when the status
moves to verified/rejected, and cleared when it returns to unverified.

Nothing is removed from the admin; it stays as the superuser fallback.
Adds an nginx /tiles/ route on the ops host so the map imagery loads there
too (the tile view is root-mounted but ops remaps / to /ops/).
2026-09-25 16:26:59 +00:00
243d4eefcd Run with DEBUG off and add HSTS and CSP headers
DEBUG was on in production, so an error rendered a full traceback and the
DEBUG-only media route served /media/ixg-documents/... publicly, bypassing
the document permission checks. Set DJANGO_DEBUG=False in the env files
(untracked) and add friendly 500/404/403 templates.

With DEBUG off Django no longer serves /static/, so nginx does: the static
root moves to /var/www/ixg-platform-static (mounted into the app container)
because /root is not traversable by www-data.

HSTS (max-age 1y, includeSubDomains) is added to every HTTPS host, and a
pragmatic CSP to the app/ops/portal locations. 'unsafe-inline' is required
by the inline scripts/styles; self-hosting the CDN assets and nonces is a
follow-up.
2026-09-25 12:37:40 +00:00
8c8e7908fd Make logout a POST and end the Keycloak session
Clicking Logout returned 405: the base templates linked to the logout view
with a GET, which mozilla-django-oidc refuses. Both portals now post a CSRF
form instead.

A POST alone was not enough. OIDC_OP_LOGOUT_URL_METHOD is now set, so logout
builds the realm's RP-initiated end-session URL with the stored id token and
a registered post-logout redirect. Without it Keycloak kept its session and
silently signed the user back in, so the sign-in screen could never be
reached again on that browser.

Registered the post-logout redirect URIs on the ixg-platform client and
trimmed its redirect URIs to the callback on the hosts that actually serve
the platform.
2026-09-21 12:53:14 +00:00
19d67555da Add public buyer self-registration
A UK buyer can now create their own account at /portal/signup/. The screen
creates a Keycloak user through a least-privilege service account
(ixg-provisioning: manage-users, view-users) and the matching Django row as
a buyer, then sends them through the normal sign-in flow to the portal.

Staff status is derived from the role in the OIDC backend rather than
asserted, so a self-registered buyer is not staff and does not become staff
on a later sign-in. The platform root and the RBAC middleware now route
each role to its own portal, which also fixes a redirect loop that the
staff fix exposed for non-staff buyers.
2026-09-21 11:30:29 +00:00
5b6c4c48e4 Test the shipment screen, its API rules and the date inputs
Cover access on GET and POST for a buyer, each required field when blank, an ETD
after the ETA, a batch that already has a shipment (queried, not blocked), a
status change, and an edit updating in place. Add the shipment routes to both
OPS_ROUTES sweeps so the detail and edit screens render clean markup, and assert
the native date inputs carry ISO values — the defect that left the intake and
batch edit dates blank.
2026-09-17 16:03:14 +00:00
dae025f600 Enforce the shipment form's rules in the API
The ops form refuses a shipment with no vessel, container, ETD or ETA, and one
whose departure is after its arrival. The serializer now requires the same
fields and refuses the same contradiction, so the API cannot accept a record the
portal would reject. Partial updates stay partial: a status change alone does
not require the other fields, and the date check falls back to the stored values.
2026-09-17 16:03:11 +00:00
5e201b2035 Add a shipment entry screen to the ops portal
Walkthrough step 11 said shipment records came through the API or the admin
area, because the portal was read-only. The list and detail pages now carry New
shipment and Edit shipment controls, with /ops/shipments/new/ and
/ops/shipments/<uuid>/edit/ mirroring the intake and batch screens.

The form requires the fields the buyer's page reads — status, vessel, container,
ETD and ETA — and refuses a departure after the arrival. The bill of lading and
the forwarder stay optional, and the ports keep their model defaults. A batch
that already has a shipment is flagged in the dropdown and warned about after
saving rather than blocked, because a split consignment is legitimate. The batch
dropdown labels each batch with its own journey status, and a card explains that
the two statuses are independent.

Also fixes a pre-existing defect the new screen would have shared: the native
date inputs were rendered in the en-gb d/m/Y format, which the browser discards,
so the intake and batch edit forms silently opened with empty dates. They now
carry the ISO format a native date input requires.
2026-09-17 16:03:09 +00:00
b376c30300 Pin the API permission boundary for every viewset
A buyer account is a legitimate authenticated user, so IsAuthenticated alone
lets it through. Sweep every ModelViewSet endpoint and assert a buyer gets 403
on write, and on read where the API is ops-only; assert ops is not forbidden;
and keep an explicit allowlist for the chat, user, notification and subscription
reads a buyer may still make. Also assert the escalation is closed: a buyer
cannot PATCH their own role to admin, sees only themselves in the user API, and
cannot reach another user's notification or subscription.
2026-09-17 15:53:47 +00:00
8ad83a7a65 Restrict every data API to ops and admin
Batch writes were closed in 88eede5, but every other app's viewset still used
IsAuthenticated, which in this platform means any buyer. A buyer could rewrite a
document record, create a QC result (which moves the batch status and raises a
CAPA), and reach the write endpoints for CAPAs, certificates, ESG reports and
shipments.

- Use the one IsOpsOrAdmin from apps.compliance.permissions; drop the duplicate
  copy in apps/qc/views.py.
- Apply it to the whole viewset on shipments, qc, capa, esg, documents,
  certificates-of-analysis and regulatory-certs. The buyer portal is
  server-rendered through the ORM, so it needs none of these APIs.
- Keep the buyer chat working: AI interactions stay authenticated and scoped,
  and agent reads stay open while agent writes become ops/admin.
- Close a buyer-to-admin escalation in the users API: writes are admin-only and
  role/is_active are read-only, so a buyer can no longer PATCH their own row to
  role=admin and then pass every IsOpsOrAdmin check.
2026-09-17 15:53:44 +00:00
fa9295d15a Pin the batch form wording to what the form actually does 2026-09-17 14:50:07 +00:00
be2da9c6e4 Show the intake lineage and edit controls on the batch detail page 2026-09-17 14:48:23 +00:00
b4d6d4bb20 Harden the markup hygiene and seed idempotency tests
Replace the Bootstrap-prefix allowlist with a default-to-leak rule for bare
hyphenated tokens, parse tags quote-aware so a '>' inside an earlier attribute
cannot truncate the tag, render the batch edit and lineage screens in the sweep
with a seeded batch, and cover stale evidence in the seed re-run test.
2026-09-17 14:48:16 +00:00
8d209f3cbf Move conditional status classes inside the class attribute
A class list written outside the quotes left the badge colour as a bare
attribute, so the status rendered white-on-white. Also repair a stray quote on
the CoA release message, where class=text-muted mb-0 left the class unapplied.
2026-09-17 14:48:14 +00:00
30d07dba41 Drop stale evidence when the walkthrough seed is re-run
Evidence rows are keyed on the filename, so reassigning the in-flight batch's
intakes left the old harvest_document row behind. Delete anything the case no
longer references, mirroring the lineage cleanup in _batch.
2026-09-17 14:48:11 +00:00
88eede5d7a Restrict the processing-batch API to ops/admin
Any authenticated user could PATCH a batch, and the write serializer accepted
status, so a buyer could move a qc_fail batch to delivered. The buyer portal
reads batches through the ORM, not this API, so the whole viewset is closed to
ops/admin and the write serializer refuses a hand-set QC status and any
transition out of qc_fail.
2026-09-17 14:48:09 +00:00
4b4477d359 Add batch create, edit and lineage screens to the ops portal
The batch form refuses to type over a QC status or move a failed batch, and
the lineage formset refuses an allocation the intake cannot support. The
origin-facility help text and the QC status card now describe what the form
actually does, and buyer POSTs to the create and edit screens are refused.
2026-09-17 14:48:06 +00:00
6a3a902045 Enforce the intake allocation limit in the lineage API
The ops lineage screen refuses to allocate more of an intake than was
received, counting every batch, but POST /api/intake-batch-lineage/ accepted
9999 kg against a 100 kg intake. Move the rule into a shared helper so the
screen and the serializer refuse the same allocations.
2026-09-17 14:48:04 +00:00
e2a6784505 Assert rendered pages carry no unquoted multi-token attributes
The ops templates shipped with 346 of them: an unquoted attribute value ends at
the first whitespace, so the styles were never applied and status badges rendered
invisible. Assert it on the rendered HTML rather than the template source, because
a template may hold {{ }} inside an unquoted attribute that renders harmlessly —
the source is ambiguous, the response is not.
2026-09-17 14:00:10 +00:00
db05d6052a Quote the ops templates' HTML attributes so the classes apply
An unquoted attribute value ends at the first whitespace, so
``class=badge bg-danger`` is read by every browser as ``class="badge"`` plus two
meaningless attributes. Bootstrap was loading perfectly well; the markup simply
never asked for the styles. Across the ops portal that meant buttons rendered as
plain links, status badges were invisible (white text on a white background: a
failed result showed as nothing at all), the active sidebar item never
highlighted, and the flex utility classes did nothing. The buyer portal templates
quote their attributes, which is why only the ops portal looked unstyled.

346 attributes across 20 templates, rewritten mechanically with a conservative
transform that handles the three shapes in use — a Django tag as the value, a
multi-token class list, and a trailing simple conditional (the active-nav
highlight) — and leaves anything it does not recognise exactly as it was.
2026-09-17 13:57:53 +00:00
7d7248a23b Add an intake screen to the ops portal
Harvest intakes could only be recorded in the administration area or through the
API, so the first record the business actually creates was entered outside the
portal it works in. Add list, create and correct screens under Harvest Intakes.

The form is deliberately stricter than the model. HarvestIntake allows almost
everything to be blank because records also arrive by API, and the platform
copes by flagging the gaps later — the EUDR completeness check has an intake
data test whose whole job is to catch a missing date or weight. The form
therefore requires every field those checks read: reference, collector group,
origin zone, date, weight and grade.

Three things it does beyond capturing the fields: it suggests the next intake
reference for the year (editable, since it may have to match a weighbridge
ticket), fills the origin zone in from the collector group while leaving it
editable, and refuses a duplicate reference — including one differing only in
case. It queries, rather than blocks, when the chosen zone is not the one the
group is registered to, and it marks unverified zones in the dropdown, because
geolocation passes only for a verified zone.

Correcting amends the record in place: the reference a buyer or auditor quotes
does not change.
2026-09-17 13:45:39 +00:00
d5bc06802e Serve map tiles from our own origin
The origin-zone map drew its background from a public tile server, which made it
the only part of the admin page that depended on a third-party domain — and the
part that breaks. Where a viewer's network, proxy or browser filter refuses
tile.openstreetmap.org (the refusal arrives as a 403) the map never draws, while
the page, the Leaflet bundle and the polygon data are all same-origin and fine.

Fetch the tiles server-side and serve them from /tiles/{z}/{x}/{y}.png: cached
on disk, so a page opened repeatedly does not re-fetch, and gated on being
signed in, so the route is not an open tile relay. The upstream stays
configurable via LEAFLET_TILE_URL, and LEAFLET_TILE_PROXY_ENABLED=False puts the
browser back on direct fetches.

When a tile still cannot be fetched, answer an empty tile rather than an error
page and say so under the map, instead of leaving a viewer with a blank grid and
no explanation.
2026-09-17 13:00:59 +00:00
cdefbd010c Serve document and certificate files through a permission-checked download
Media was unreachable from either portal, which is why the certificate button on
the buyer compliance page returned page-not-found and no document row had
anything to click. The cause was structural: the portal and ops hosts proxy
location / to the app with a path prefix, so a stored /media/... URL arrives as
/portal/media/... and matches no route.

Add a download view per portal behind the same role gate as the page that links
to it — buyer-visible documents and published certificates for buyers, anything
the ops list shows for operations — resolving the stored path safely under
MEDIA_ROOT so a traversal or an absolute path is refused. Point the seven
templates that list files at the new routes.

Also add a portal-wide render sweep: nothing in the suite was fetching the ops
or portal pages, so a view could lose its template_name and 500 in front of a
user with every test still green.
2026-09-17 12:44:52 +00:00
5d0b5fe757 Origin zones: draw the stored polygon on a map in the admin (Leaflet 1.9.4)
Closes the "no map view" gap for origin zones: the polygon was stored as
coordinates and shown as raw JSON, so the shape could not be checked by eye.

- vendor Leaflet 1.9.4 into apps/core/static/vendor/leaflet. Self-hosted rather
  than pulled from a CDN, because this renders supplier-location data.
  PROVENANCE.md records the version, source, licence (BSD-2-Clause), checksums
  and upgrade steps; the checksums match the SRI hashes Leaflet publishes
- OriginZoneAdmin gains an "Area on a map" read-only row and its Media assets;
  apps/compliance/static/js/ixg_zone_map.js draws the polygon from the stored
  geometry, passed through a data attribute rather than injected into a script
- the tile source is a setting (LEAFLET_TILE_URL), so moving to self-hosted
  tiles is a config change, not a code change

Deliberately display only: it draws what is stored. It does not validate the
geometry, and it does not verify the zone - both remain open work, and the page
says so where the map appears.

Tests cover the drawn map, a zone with no coordinates, and the add page.
2026-09-16 16:39:07 +00:00
4667637265 Add seed_walkthrough management command
Builds one coherent, fully fictional batch chain for ops training, so the same
batch reference appears on every screen a trainer walks through: origin zone and
collector groups, harvest intakes and lineage, processing batch, QC result and
four-eyes verification, certificate of analysis with its supporting documents,
EUDR due-diligence case, shipment and ESG report.

Three batches on purpose: a completed happy path, one mid-flight, and one QC
failure with a CAPA so the exception branch has something to show.

Strictly synthetic data - no real buyer, supplier, collector or producer data.
2026-09-16 16:39:07 +00:00
c568a34dc0 Ops templates: show '-' when the acting user is null
Six ops list and detail pages dereferenced a nullable user to print their name,
which renders an empty cell when the field is unset. Guard each one so the cell
reads "-" instead.

Screens: batch detail, CoA review detail, document list, EUDR detail, and the two
QC verification pages.
2026-09-16 16:39:07 +00:00
ee4f7b5232 Help centre: portal-scoped guide audiences, and links that resolve
Guide visibility is now scoped by PORTAL first and role second:

- the buyer portal lists shared and buyer guides, even for an admin, because it
  is the buyer portal; admin guides never surface there
- the ops portal lists shared and ops guides, plus admin guides for an
  admin/superuser
- the detail view names its context object, so guide pages get a real browser
  title instead of a blank one

Link rewriting, so one page can serve both the wiki and the in-app help centre:

- absolute /en/user-guides/... links become the in-app help path
- other absolute /en/... links point at the wiki itself; the app hosts serve no
  /en/ routes, so leaving them untouched was a 404 for whoever clicked
- relative links keep their existing behaviour

Tests cover the new rewriting and the portal scoping rules. Also adds
docs/USER_GUIDES_VIEWER_SPEC.md, the spec this viewer was built to.
2026-09-16 16:39:07 +00:00
8f1649aaa1 Deployment hardening: HTTPS/CSRF settings, loopback-only app port, entrypoint passthrough
- settings: trust nginx X-Forwarded-Proto, mark session and CSRF cookies secure,
  and set the CSRF trusted origins for the ixgltd.com hosts
- docker-compose: bind the app port to 127.0.0.1 only; nginx fronts it, so there
  is no reason for 0.0.0.0
- entrypoint: when the container is given a command, run that instead of always
  starting gunicorn (needed for manage.py shell and the test suite)
- gitignore: keep Celery runtime state and stray local backups out of the tree

No functional change to the application itself.
2026-09-16 16:39:07 +00:00
6508cc25ef Add User Guides Viewer (Help Centre) - role-gated wiki help pages
- New apps/guides app with GuidePage model and audience mapping by wiki path
- sync_guides management command: one-way read-only sync from wikijs DB
- Read-only wikijs_ro DB role + WIKI_DB_* env config (secrets, gitignored)
- Markdown rendering with bleach sanitisation; rewrites internal guide links
  to the portal help root and wiki image paths to WIKI_PUBLIC_BASE
- /portal/help/ (buyer) and /ops/help/ (ops) index + detail views with
  role-based audience filtering (404 on cross-role guides)
- Help nav item added to both portal base templates
- Tests: audience filtering, markdown sanitisation, link rewriting, sync command
2026-08-07 14:19:49 +00:00
112 changed files with 10635 additions and 557 deletions

6
.gitignore vendored
View file

@ -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

View file

@ -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

View file

@ -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
View 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)

View file

@ -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
View 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
View 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")

View file

@ -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'),
]

View file

@ -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"

View file

@ -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]

View file

@ -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)

View file

@ -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)

View file

@ -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())

View 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 =
'&copy; <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();
}
})();

View file

@ -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())

View file

View 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}"
))

View file

@ -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
View 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.

View 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`.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View 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;
}
}

File diff suppressed because one or more lines are too long

854
apps/core/tests.py Normal file
View 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
View 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
View 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/")

View 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
View 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))

View file

@ -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]

View file

@ -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
View file

11
apps/guides/admin.py Normal file
View 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
View 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"

View file

View 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
View 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,
)

View 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'],
},
),
]

View file

74
apps/guides/models.py Normal file
View 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
View 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(
"![Dashboard](/buyer-guide/buyer-dashboard.png)",
"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
View 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

File diff suppressed because it is too large Load diff

View 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

File diff suppressed because it is too large Load diff

View file

@ -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'),
]

View file

@ -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)

View file

@ -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."""

View file

@ -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
View 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)

View file

@ -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]

View file

@ -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
View 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]

View 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
View 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)}"

View file

@ -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
View 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")

View file

@ -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

View file

@ -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: .

View 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.

View file

@ -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",

View file

@ -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")),

View file

@ -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
View 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
View 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
View 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>

View 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">&larr; 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View file

@ -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>

View file

@ -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 &mdash; 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>

View 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> &mdash; moved here, by you.</li>
<li><strong>QC Fail</strong> &mdash; 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> &mdash; 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 &mdash; a retest &mdash; 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 %}

View 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> &mdash; the batch needs at least one intake</li>
<li><strong>Geolocation</strong> &mdash; every linked intake must point at a
<em>verified</em> origin zone</li>
<li><strong>Intake data</strong> &mdash; 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 &mdash; the intake record itself
is untouched.
</p>
</div>
</div>
</div>
{% endblock %}

View file

@ -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>&laquo;</a></li>
<li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>
<li class="page-item"><a class="page-link" href="?page=1">&laquo;</a></li>
<li class="page-item"><a class="page-link" href="?page="{{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>
{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}
<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li>
<li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>
<li class="page-item"><a class="page-link" href="?page="{{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li>
<li class="page-item"><a class="page-link" href="?page="{{ page_obj.paginator.num_pages }}>&raquo;</a></li>
{% endif %}
</ul>
</nav>

View file

@ -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>

View file

@ -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>&laquo;</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
<nav class="mt-3"><ul class="pagination pagination-sm justify-content-center">
{% if page_obj.has_previous %}<li class="page-item"><a class="page-link" href="?page=1">&laquo;</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class="page-item"><a class="page-link" href="?page="{{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
</ul></nav>
{% endif %}

View file

@ -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 &amp; 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 &amp; 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>

View file

@ -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>&laquo;</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
<nav class="mt-3"><ul class="pagination pagination-sm justify-content-center">
{% if page_obj.has_previous %}<li class="page-item"><a class="page-link" href="?page=1">&laquo;</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class="page-item"><a class="page-link" href="?page="{{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
</ul></nav>
{% endif %}

View 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">&laquo;</a></li>
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">{{ page_obj.previous_page_number }}</a></li>
{% endif %}
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">{{ page_obj.next_page_number }}</a></li>
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">&raquo;</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

View file

@ -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>

View file

@ -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>&laquo;</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
<nav class="mt-3"><ul class="pagination pagination-sm justify-content-center">
{% if page_obj.has_previous %}<li class="page-item"><a class="page-link" href="?page=1">&laquo;</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class="page-item"><a class="page-link" href="?page="{{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
</ul></nav>
{% endif %}

View file

@ -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>&laquo;</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
<nav class="mt-3"><ul class="pagination pagination-sm justify-content-center">
{% if page_obj.has_previous %}<li class="page-item"><a class="page-link" href="?page=1">&laquo;</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class="page-item"><a class="page-link" href="?page="{{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
</ul></nav>
{% endif %}

View file

@ -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 %}&middot; {{ e.source }}{% endif %}{% if e.collected_at %} &middot; {{ 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> &middot; {{ s.submission_date|date:'d/m/Y'|default:'-' }} &middot; 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>

View file

@ -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>&laquo;</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
<nav class="mt-3"><ul class="pagination pagination-sm justify-content-center">
{% if page_obj.has_previous %}<li class="page-item"><a class="page-link" href="?page=1">&laquo;</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class="page-item"><a class="page-link" href="?page="{{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
</ul></nav>
{% endif %}

View 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">&laquo;</a></li>
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">{{ page_obj.previous_page_number }}</a></li>
{% endif %}
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">{{ page_obj.next_page_number }}</a></li>
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">&raquo;</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,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 %}

View 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">&laquo;</a></li>
<li class="page-item"><a class="page-link" href="?page="{{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>
{% endif %}
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page="{{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li>
<li class="page-item"><a class="page-link" href="?page="{{ page_obj.paginator.num_pages }}>&raquo;</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,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">&laquo;</a></li>
<li class="page-item"><a class="page-link" href="?page={{ page_obj.previous_page_number }}">{{ page_obj.previous_page_number }}</a></li>
{% endif %}
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?page={{ page_obj.next_page_number }}">{{ page_obj.next_page_number }}</a></li>
<li class="page-item"><a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">&raquo;</a></li>
{% endif %}
</ul>
</nav>
{% endif %}
{% endblock %}

View file

@ -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>

View file

@ -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>&laquo;</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
<nav class="mt-3"><ul class="pagination pagination-sm justify-content-center">
{% if page_obj.has_previous %}<li class="page-item"><a class="page-link" href="?page=1">&laquo;</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class="page-item"><a class="page-link" href="?page="{{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
</ul></nav>
{% endif %}

View file

@ -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 &amp; Generate CoA</button>
<button type=submit name=approved value=0 class="btn btn-outline-danger w-100"><i class=bi bi-x-circle></i> Reject</button>
<p class="text-muted">This action is recorded as the quality approval. The submitter cannot approve their own result.</p>
<button type="submit" name="approved" value="1" class="btn btn-success w-100 mb-2"><i class="bi bi-check-circle"></i> Approve &amp; Generate CoA</button>
<button type="submit" name="approved" value="0" class="btn btn-outline-danger w-100"><i class="bi bi-x-circle"></i> Reject</button>
</form>
{% else %}
<p><span class=badge {% if result.verification_status == 'approved' %}bg-success{% else %}bg-danger{% endif %}>{{ result.get_verification_status_display }}</span></p>
{% if result.verified_by %}<p class=text-muted mb-0>Verified by {{ result.verified_by.full_name|default:result.verified_by.email }} on {{ result.approved_at|date:'d/m/Y H:i' }}</p>{% endif %}
<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>

View file

@ -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>&laquo;</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class=page-item active><span class=page-link>{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class=page-item><a class=page-link href=?page={{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class=page-item><a class=page-link href=?page={{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
<nav class="mt-3"><ul class="pagination pagination-sm justify-content-center">
{% if page_obj.has_previous %}<li class="page-item"><a class="page-link" href="?page=1">&laquo;</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.previous_page_number }}>{{ page_obj.previous_page_number }}</a></li>{% endif %}
<li class="page-item active"><span class="page-link">{{ page_obj.number }}</span></li>
{% if page_obj.has_next %}<li class="page-item"><a class="page-link" href="?page="{{ page_obj.next_page_number }}>{{ page_obj.next_page_number }}</a></li><li class="page-item"><a class="page-link" href="?page="{{ page_obj.paginator.num_pages }}>&raquo;</a></li>{% endif %}
</ul></nav>
{% endif %}

Some files were not shown because too many files have changed in this diff Show more