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.
This commit is contained in:
parent
5d0b5fe757
commit
cdefbd010c
14 changed files with 402 additions and 10 deletions
|
|
@ -10,6 +10,8 @@ urlpatterns = [
|
||||||
path('batches/', views.BatchListView.as_view(), name='batch_list'),
|
path('batches/', views.BatchListView.as_view(), name='batch_list'),
|
||||||
path('batches/<uuid:pk>/', views.BatchDetailView.as_view(), name='batch_detail'),
|
path('batches/<uuid:pk>/', views.BatchDetailView.as_view(), name='batch_detail'),
|
||||||
path('documents/', views.DocumentListView.as_view(), name='document_list'),
|
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('subscription/', views.SubscriptionView.as_view(), name='subscription'),
|
||||||
path('compliance/', views.ComplianceView.as_view(), name='compliance'),
|
path('compliance/', views.ComplianceView.as_view(), name='compliance'),
|
||||||
path('help/', views.GuideIndexView.as_view(), name='guide_index'),
|
path('help/', views.GuideIndexView.as_view(), name='guide_index'),
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.views import View
|
||||||
from django.views.generic import TemplateView, ListView, DetailView
|
from django.views.generic import TemplateView, ListView, DetailView
|
||||||
|
|
||||||
from apps.batches.models import ProcessingBatch
|
from apps.batches.models import ProcessingBatch
|
||||||
from apps.shipments.models import Shipment
|
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.esg.models import ESGFieldReport
|
||||||
from apps.subscriptions.models import Subscription
|
from apps.subscriptions.models import Subscription
|
||||||
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
from apps.guides.views import GuideDetailView as BaseGuideDetailView
|
||||||
|
|
@ -88,6 +91,35 @@ class DocumentListView(BuyerRequiredMixin, ListView):
|
||||||
return ctx
|
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):
|
class SubscriptionView(BuyerRequiredMixin, DetailView):
|
||||||
model = Subscription
|
model = Subscription
|
||||||
template_name = "portal/subscription.html"
|
template_name = "portal/subscription.html"
|
||||||
|
|
|
||||||
59
apps/core/tests.py
Normal file
59
apps/core/tests.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Every portal page must actually render.
|
||||||
|
|
||||||
|
Nothing in the suite was rendering the ops or portal HTML pages, which is how a
|
||||||
|
view can lose a line — a `template_name`, say — and 500 in front of a user while
|
||||||
|
68 tests still pass. These sweeps fetch each read-only page with an empty
|
||||||
|
database, so they cover the empty-state branches as well as the templates.
|
||||||
|
"""
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
OPS_ROUTES = [
|
||||||
|
"/ops/",
|
||||||
|
"/ops/batches/",
|
||||||
|
"/ops/qc/",
|
||||||
|
"/ops/qc/verify/",
|
||||||
|
"/ops/coa/",
|
||||||
|
"/ops/shipments/",
|
||||||
|
"/ops/capa/",
|
||||||
|
"/ops/documents/",
|
||||||
|
"/ops/esg/",
|
||||||
|
"/ops/eudr/",
|
||||||
|
"/ops/help/",
|
||||||
|
]
|
||||||
|
|
||||||
|
PORTAL_ROUTES = [
|
||||||
|
"/portal/",
|
||||||
|
"/portal/batches/",
|
||||||
|
"/portal/documents/",
|
||||||
|
"/portal/compliance/",
|
||||||
|
"/portal/subscription/",
|
||||||
|
"/portal/help/",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
self.sweep(self.make_user("ops-sweep@ixg.local", "ops"), 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")
|
||||||
60
apps/documents/downloads.py
Normal file
60
apps/documents/downloads.py
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"""Permission-checked document downloads.
|
||||||
|
|
||||||
|
Neither portal can serve a stored document today, and the reason is structural
|
||||||
|
rather than a missing link. The portals sit behind nginx servers that proxy
|
||||||
|
``location /`` to the Django app **with a path prefix** (``/portal/`` and
|
||||||
|
``/ops/``), so a stored ``/media/...`` URL arrives at Django as
|
||||||
|
``/portal/media/...`` — a route that does not exist. Django's own DEBUG media
|
||||||
|
route is registered at ``/media/``, not under either prefix, so even a file that
|
||||||
|
is present on disk comes back as a page-not-found.
|
||||||
|
|
||||||
|
Serving media directly from the web server would not be the answer either:
|
||||||
|
every document row carries a visibility setting (buyer / ops / admin) and every
|
||||||
|
certificate carries a status, so the file behind a row is not public. The
|
||||||
|
download has to pass through the same permission gate as the page that links to
|
||||||
|
it.
|
||||||
|
|
||||||
|
So: these helpers resolve a stored ``file_url`` to a real path under
|
||||||
|
``MEDIA_ROOT`` — refusing anything that escapes it — and stream the bytes back
|
||||||
|
to a caller that has already passed a portal permission gate.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import FileResponse, Http404
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_media_path(file_url):
|
||||||
|
"""Absolute path for a stored ``/media/...`` file_url, or None if unacceptable.
|
||||||
|
|
||||||
|
None means "do not serve this": an empty value, a path outside MEDIA_URL, or
|
||||||
|
a path that resolves outside MEDIA_ROOT (``../`` and symlink escapes).
|
||||||
|
"""
|
||||||
|
if not file_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
media_url = settings.MEDIA_URL.rstrip("/") # "/media"
|
||||||
|
rel = file_url.strip()
|
||||||
|
if rel.startswith(media_url + "/"):
|
||||||
|
rel = rel[len(media_url) + 1:]
|
||||||
|
elif rel.startswith("/"):
|
||||||
|
# An absolute filesystem path, rather than a path under MEDIA_URL.
|
||||||
|
return None
|
||||||
|
|
||||||
|
base = Path(settings.MEDIA_ROOT).resolve()
|
||||||
|
target = (base / rel).resolve()
|
||||||
|
if target != base and base not in target.parents:
|
||||||
|
return None
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def file_response(file_url, download_name=None):
|
||||||
|
"""Stream a stored file back as an inline response.
|
||||||
|
|
||||||
|
Raises 404 when the value cannot be resolved or the file is not on disk, so a
|
||||||
|
broken row reads as "not found" rather than as a server error.
|
||||||
|
"""
|
||||||
|
path = resolve_media_path(file_url)
|
||||||
|
if path is None or not path.is_file():
|
||||||
|
raise Http404("Document file not found")
|
||||||
|
return FileResponse(open(path, "rb"), filename=download_name or path.name)
|
||||||
209
apps/documents/tests.py
Normal file
209
apps/documents/tests.py
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
"""Downloads must be gated the same way the page that links to them is.
|
||||||
|
|
||||||
|
Two classes of failure matter here and neither is caught by "the link renders":
|
||||||
|
a file that cannot be served at all (the state the portals were in), and a file
|
||||||
|
served to someone who should not have it. Both are covered below, plus the path
|
||||||
|
handling that turns a stored value into a real file.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.test import Client, TestCase, override_settings
|
||||||
|
|
||||||
|
from apps.batches.models import ProcessingBatch
|
||||||
|
|
||||||
|
from .downloads import resolve_media_path
|
||||||
|
from .models import CertificateOfAnalysis, Document
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
PDF_BYTES = b"%PDF-1.4\n% synthetic document for tests\n%%EOF\n"
|
||||||
|
|
||||||
|
|
||||||
|
def make_user(email, role):
|
||||||
|
# Mirrors the Keycloak OIDC backend, which creates all users as is_staff=True.
|
||||||
|
return User.objects.create_user(
|
||||||
|
email=email, password="pass1234", role=role,
|
||||||
|
full_name=email.split("@")[0], is_staff=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadTestCase(TestCase):
|
||||||
|
"""Fixtures: a real temp media root, plus one user per role."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.media_root = Path(tempfile.mkdtemp(prefix="ixg-download-test-"))
|
||||||
|
self.addCleanup(shutil.rmtree, self.media_root, True)
|
||||||
|
self.settings_override = override_settings(MEDIA_ROOT=self.media_root)
|
||||||
|
self.settings_override.enable()
|
||||||
|
self.addCleanup(self.settings_override.disable)
|
||||||
|
|
||||||
|
self.buyer = make_user("buyer@ixg.local", "buyer")
|
||||||
|
self.ops = make_user("ops@ixg.local", "ops")
|
||||||
|
self.admin = make_user("admin@ixg.local", "admin")
|
||||||
|
|
||||||
|
self.batch = ProcessingBatch.objects.create(
|
||||||
|
batch_reference="IXG-BATCH-TEST-1",
|
||||||
|
grade="A",
|
||||||
|
weight_kg=Decimal("1000.00"),
|
||||||
|
origin_facility="ASI - Katsina State, Nigeria",
|
||||||
|
created_by=self.ops,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.buyer_doc = self.make_document("packing-list.pdf", Document.Visibility.BUYER)
|
||||||
|
self.ops_doc = self.make_document("lab-report.pdf", Document.Visibility.OPS)
|
||||||
|
self.admin_doc = self.make_document("internal-note.pdf", Document.Visibility.ADMIN)
|
||||||
|
self.published_coa = self.make_coa("coa-published.pdf", CertificateOfAnalysis.Status.PUBLISHED)
|
||||||
|
self.pending_coa = self.make_coa("coa-pending.pdf", CertificateOfAnalysis.Status.PENDING_APPROVAL)
|
||||||
|
|
||||||
|
def write_file(self, name):
|
||||||
|
"""Put real bytes on disk under MEDIA_ROOT and return the stored file_url."""
|
||||||
|
rel = f"ixg-documents/{self.batch.batch_reference.lower()}/{name}"
|
||||||
|
target = self.media_root / rel
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
target.write_bytes(PDF_BYTES)
|
||||||
|
return f"/media/{rel}"
|
||||||
|
|
||||||
|
def make_document(self, name, visibility):
|
||||||
|
return Document.objects.create(
|
||||||
|
batch=self.batch,
|
||||||
|
doc_type=Document.DocType.OTHER,
|
||||||
|
file_url=self.write_file(name),
|
||||||
|
file_name=name,
|
||||||
|
visibility=visibility,
|
||||||
|
uploaded_by=self.ops,
|
||||||
|
)
|
||||||
|
|
||||||
|
def make_coa(self, name, status):
|
||||||
|
return CertificateOfAnalysis.objects.create(
|
||||||
|
batch=self.batch,
|
||||||
|
file_url=self.write_file(name),
|
||||||
|
file_name=name,
|
||||||
|
status=status,
|
||||||
|
uploaded_by=self.ops,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, user, url, host):
|
||||||
|
c = Client(SERVER_NAME=host)
|
||||||
|
c.force_login(user)
|
||||||
|
return c.get(url)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def body(response):
|
||||||
|
if getattr(response, "streaming", False):
|
||||||
|
return b"".join(response.streaming_content)
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
|
||||||
|
class BuyerDownloadTests(DownloadTestCase):
|
||||||
|
def test_buyer_downloads_a_buyer_visible_document(self):
|
||||||
|
r = self.get(self.buyer, f"/portal/documents/{self.buyer_doc.id}/download/", "portal.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(self.body(r), PDF_BYTES)
|
||||||
|
self.assertIn("packing-list.pdf", r.headers.get("Content-Disposition", ""))
|
||||||
|
|
||||||
|
def test_buyer_cannot_download_an_operations_document(self):
|
||||||
|
r = self.get(self.buyer, f"/portal/documents/{self.ops_doc.id}/download/", "portal.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
def test_buyer_cannot_download_an_admin_document(self):
|
||||||
|
r = self.get(self.buyer, f"/portal/documents/{self.admin_doc.id}/download/", "portal.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
def test_buyer_downloads_a_published_certificate(self):
|
||||||
|
r = self.get(self.buyer, f"/portal/certificates/{self.published_coa.id}/download/", "portal.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(self.body(r), PDF_BYTES)
|
||||||
|
|
||||||
|
def test_buyer_cannot_download_a_certificate_awaiting_release(self):
|
||||||
|
r = self.get(self.buyer, f"/portal/certificates/{self.pending_coa.id}/download/", "portal.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
def test_anonymous_visitor_is_refused(self):
|
||||||
|
c = Client(SERVER_NAME="portal.ixgltd.com")
|
||||||
|
r = c.get(f"/portal/documents/{self.buyer_doc.id}/download/")
|
||||||
|
self.assertIn(r.status_code, (302, 403))
|
||||||
|
|
||||||
|
def test_ops_user_is_refused_on_the_buyer_route(self):
|
||||||
|
r = self.get(self.ops, f"/portal/documents/{self.buyer_doc.id}/download/", "portal.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 403)
|
||||||
|
|
||||||
|
|
||||||
|
class OpsDownloadTests(DownloadTestCase):
|
||||||
|
def test_ops_downloads_a_buyer_visible_document(self):
|
||||||
|
r = self.get(self.ops, f"/ops/documents/{self.buyer_doc.id}/download/", "ops.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(self.body(r), PDF_BYTES)
|
||||||
|
|
||||||
|
def test_ops_downloads_an_operations_document(self):
|
||||||
|
r = self.get(self.ops, f"/ops/documents/{self.ops_doc.id}/download/", "ops.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
|
def test_ops_can_open_a_certificate_awaiting_release(self):
|
||||||
|
"""A reviewer needs the pending certificate most of all."""
|
||||||
|
r = self.get(self.ops, f"/ops/certificates/{self.pending_coa.id}/download/", "ops.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(self.body(r), PDF_BYTES)
|
||||||
|
|
||||||
|
def test_buyer_is_refused_on_the_ops_route(self):
|
||||||
|
r = self.get(self.buyer, f"/ops/documents/{self.ops_doc.id}/download/", "ops.ixgltd.com")
|
||||||
|
self.assertIn(r.status_code, (302, 403))
|
||||||
|
|
||||||
|
|
||||||
|
class MissingAndHostileFileTests(DownloadTestCase):
|
||||||
|
def test_missing_file_is_not_found_rather_than_a_server_error(self):
|
||||||
|
doc = Document.objects.create(
|
||||||
|
batch=self.batch,
|
||||||
|
doc_type=Document.DocType.OTHER,
|
||||||
|
file_url="/media/ixg-documents/nowhere/nothing-here.pdf",
|
||||||
|
file_name="nothing-here.pdf",
|
||||||
|
visibility=Document.Visibility.BUYER,
|
||||||
|
uploaded_by=self.ops,
|
||||||
|
)
|
||||||
|
r = self.get(self.buyer, f"/portal/documents/{doc.id}/download/", "portal.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
def test_empty_file_url_is_not_found(self):
|
||||||
|
doc = Document.objects.create(
|
||||||
|
batch=self.batch,
|
||||||
|
doc_type=Document.DocType.OTHER,
|
||||||
|
file_url="",
|
||||||
|
file_name="empty.pdf",
|
||||||
|
visibility=Document.Visibility.BUYER,
|
||||||
|
uploaded_by=self.ops,
|
||||||
|
)
|
||||||
|
r = self.get(self.buyer, f"/portal/documents/{doc.id}/download/", "portal.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
def test_a_traversal_out_of_media_root_is_refused(self):
|
||||||
|
doc = Document.objects.create(
|
||||||
|
batch=self.batch,
|
||||||
|
doc_type=Document.DocType.OTHER,
|
||||||
|
file_url="/media/../../config/settings.py",
|
||||||
|
file_name="escaped.pdf",
|
||||||
|
visibility=Document.Visibility.BUYER,
|
||||||
|
uploaded_by=self.ops,
|
||||||
|
)
|
||||||
|
r = self.get(self.buyer, f"/portal/documents/{doc.id}/download/", "portal.ixgltd.com")
|
||||||
|
self.assertEqual(r.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class ResolveMediaPathTests(DownloadTestCase):
|
||||||
|
def test_resolves_a_normal_media_url(self):
|
||||||
|
path = resolve_media_path(self.buyer_doc.file_url)
|
||||||
|
self.assertTrue(path and path.is_file())
|
||||||
|
|
||||||
|
def test_refuses_paths_outside_media_url(self):
|
||||||
|
self.assertIsNone(resolve_media_path("/etc/passwd"))
|
||||||
|
self.assertIsNone(resolve_media_path("/app/config/settings.py"))
|
||||||
|
|
||||||
|
def test_refuses_traversal(self):
|
||||||
|
self.assertIsNone(resolve_media_path("/media/../config/settings.py"))
|
||||||
|
|
||||||
|
def test_refuses_empty_values(self):
|
||||||
|
self.assertIsNone(resolve_media_path(""))
|
||||||
|
self.assertIsNone(resolve_media_path(None))
|
||||||
|
|
@ -14,6 +14,8 @@ urlpatterns = [
|
||||||
path('capa/', views.CAPAListView.as_view(), name='capa_list'),
|
path('capa/', views.CAPAListView.as_view(), name='capa_list'),
|
||||||
path('capa/<uuid:pk>/', views.CAPADetailView.as_view(), name='capa_detail'),
|
path('capa/<uuid:pk>/', views.CAPADetailView.as_view(), name='capa_detail'),
|
||||||
path('documents/', views.DocumentListView.as_view(), name='document_list'),
|
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('esg/', views.ESGListView.as_view(), name='esg_list'),
|
||||||
path('qc/verify/', views.QCVerifyListView.as_view(), name='qc_verify_list'),
|
path('qc/verify/', views.QCVerifyListView.as_view(), name='qc_verify_list'),
|
||||||
path('qc/verify/<uuid:pk>/', views.QCVerifyDetailView.as_view(), name='qc_verify_detail'),
|
path('qc/verify/<uuid:pk>/', views.QCVerifyDetailView.as_view(), name='qc_verify_detail'),
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ from django.db.models import Count, Q
|
||||||
from django.shortcuts import redirect
|
from django.shortcuts import redirect
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.views.generic import TemplateView, ListView, DetailView
|
from django.views.generic import TemplateView, ListView, DetailView
|
||||||
|
from django.views import View
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
from apps.batches.models import ProcessingBatch
|
from apps.batches.models import ProcessingBatch
|
||||||
from apps.qc.models import QCResult
|
from apps.qc.models import QCResult
|
||||||
from apps.shipments.models import Shipment
|
from apps.shipments.models import Shipment
|
||||||
from apps.capa.models import CAPARecord
|
from apps.capa.models import CAPARecord
|
||||||
|
from apps.documents.downloads import file_response
|
||||||
from apps.documents.models import Document, CertificateOfAnalysis
|
from apps.documents.models import Document, CertificateOfAnalysis
|
||||||
from apps.esg.models import ESGFieldReport
|
from apps.esg.models import ESGFieldReport
|
||||||
from apps.notifications.models import Notification
|
from apps.notifications.models import Notification
|
||||||
|
|
@ -191,6 +193,32 @@ class DocumentListView(OpsRequiredMixin, ListView):
|
||||||
return ctx
|
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):
|
class ESGListView(OpsRequiredMixin, ListView):
|
||||||
model = ESGFieldReport
|
model = ESGFieldReport
|
||||||
template_name = 'ops/esg_list.html'
|
template_name = 'ops/esg_list.html'
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@
|
||||||
{% if documents %}
|
{% if documents %}
|
||||||
<ul class=list-unstyled mb-0>
|
<ul class=list-unstyled mb-0>
|
||||||
{% for d in documents %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}<p class=text-muted mb-0>No documents</p>{% endif %}
|
{% else %}<p class=text-muted mb-0>No documents</p>{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
<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>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>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>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>
|
<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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -36,14 +36,14 @@
|
||||||
<h6 class=mb-3>Source Documents</h6>
|
<h6 class=mb-3>Source Documents</h6>
|
||||||
<p class=small text-muted>Independent laboratory report (authoritative):</p>
|
<p class=small text-muted>Independent laboratory report (authoritative):</p>
|
||||||
{% if coa.source_document %}
|
{% 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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
{% if lab_documents %}
|
{% if lab_documents %}
|
||||||
<hr>
|
<hr>
|
||||||
<p class=small text-muted mb-1>All lab documents for batch:</p>
|
<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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class=stat-card>
|
<div class=stat-card>
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for d in documents %}
|
{% for d in documents %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class=bi bi-file-earmark></i> {{ d.file_name }}</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><span class=badge bg-light text-dark>{{ d.get_doc_type_display }}</span></td>
|
||||||
<td>{{ d.batch.batch_reference|default:'-' }}</td>
|
<td>{{ d.batch.batch_reference|default:'-' }}</td>
|
||||||
<td><span class=badge bg-info text-dark>{{ d.get_visibility_display }}</span></td>
|
<td><span class=badge bg-info text-dark>{{ d.get_visibility_display }}</span></td>
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
{% if documents %}
|
{% if documents %}
|
||||||
<ul class="list-unstyled mb-0">
|
<ul class="list-unstyled mb-0">
|
||||||
{% for d in documents %}
|
{% 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 'portal:document_download' d.id %}" target="_blank"><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 %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}<p class="text-muted mb-0">No documents</p>{% endif %}
|
{% else %}<p class="text-muted mb-0">No documents</p>{% endif %}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
<td><strong>{{ c.batch.batch_reference }}</strong></td>
|
<td><strong>{{ c.batch.batch_reference }}</strong></td>
|
||||||
<td>Unrefined Shea Butter</td>
|
<td>Unrefined Shea Butter</td>
|
||||||
<td>{{ c.issue_date|date:'d/m/Y'|default:'-' }}</td>
|
<td>{{ c.issue_date|date:'d/m/Y'|default:'-' }}</td>
|
||||||
<td><a href="{{ c.file_url }}" target="_blank" class="btn btn-sm btn-outline-success"><i class="bi bi-download"></i> PDF</a></td>
|
<td><a href="{% url 'portal:coa_download' c.id %}" target="_blank" class="btn btn-sm btn-outline-success"><i class="bi bi-download"></i> PDF</a></td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr><td colspan="4" class="text-muted text-center py-3">No approved certificates available</td></tr>
|
<tr><td colspan="4" class="text-muted text-center py-3">No approved certificates available</td></tr>
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
<h6 class="mb-3">Recent Documents</h6>
|
<h6 class="mb-3">Recent Documents</h6>
|
||||||
{% for d in documents %}
|
{% for d in documents %}
|
||||||
<div class="border-bottom pb-2 mb-2">
|
<div class="border-bottom pb-2 mb-2">
|
||||||
<strong>{{ d.file_name }}</strong><br>
|
<a href="{% url 'portal:document_download' d.id %}" target="_blank"><strong>{{ d.file_name }}</strong></a><br>
|
||||||
<span class="text-muted small">{{ d.get_doc_type_display }} · {{ d.uploaded_at|date:'d/m/Y' }}</span>
|
<span class="text-muted small">{{ d.get_doc_type_display }} · {{ d.uploaded_at|date:'d/m/Y' }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for d in documents %}
|
{% for d in documents %}
|
||||||
<tr>
|
<tr>
|
||||||
<td><i class="bi bi-file-earmark"></i> {{ d.file_name }}</td>
|
<td><a href="{% url 'portal:document_download' d.id %}" target="_blank"><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><span class="badge bg-light text-dark">{{ d.get_doc_type_display }}</span></td>
|
||||||
<td>{{ d.batch.batch_reference|default:'-' }}</td>
|
<td>{{ d.batch.batch_reference|default:'-' }}</td>
|
||||||
<td>{{ d.uploaded_at|date:'d/m/Y' }}</td>
|
<td>{{ d.uploaded_at|date:'d/m/Y' }}</td>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue