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.
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.
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/).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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