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/<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'),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
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 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
|
||||
|
|
@ -88,6 +91,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"
|
||||
|
|
|
|||
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/<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'),
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ from django.db.models import Count, Q
|
|||
from django.shortcuts import redirect
|
||||
from django.utils import timezone
|
||||
from django.views.generic import TemplateView, ListView, DetailView
|
||||
from django.views import View
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from apps.batches.models import ProcessingBatch
|
||||
from apps.qc.models import QCResult
|
||||
from apps.shipments.models import Shipment
|
||||
from apps.capa.models import CAPARecord
|
||||
from apps.documents.downloads import file_response
|
||||
from apps.documents.models import Document, CertificateOfAnalysis
|
||||
from apps.esg.models import ESGFieldReport
|
||||
from apps.notifications.models import Notification
|
||||
|
|
@ -191,6 +193,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'
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@
|
|||
{% if documents %}
|
||||
<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 %}
|
||||
|
|
|
|||
|
|
@ -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>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>
|
||||
<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>
|
||||
|
|
@ -36,14 +36,14 @@
|
|||
<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>
|
||||
{% 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 %}
|
||||
{% 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>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@
|
|||
<tbody>
|
||||
{% for d in documents %}
|
||||
<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>{{ d.batch.batch_reference|default:'-' }}</td>
|
||||
<td><span class=badge bg-info text-dark>{{ d.get_visibility_display }}</span></td>
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@
|
|||
{% if documents %}
|
||||
<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 '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 %}
|
||||
</ul>
|
||||
{% else %}<p class="text-muted mb-0">No documents</p>{% endif %}
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
<td><strong>{{ c.batch.batch_reference }}</strong></td>
|
||||
<td>Unrefined Shea Butter</td>
|
||||
<td>{{ c.issue_date|date:'d/m/Y'|default:'-' }}</td>
|
||||
<td><a href="{{ c.file_url }}" target="_blank" class="btn btn-sm btn-outline-success"><i class="bi bi-download"></i> PDF</a></td>
|
||||
<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>
|
||||
{% empty %}
|
||||
<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>
|
||||
{% for d in documents %}
|
||||
<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>
|
||||
</div>
|
||||
{% empty %}
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@
|
|||
<tbody>
|
||||
{% for d in documents %}
|
||||
<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>{{ d.batch.batch_reference|default:'-' }}</td>
|
||||
<td>{{ d.uploaded_at|date:'d/m/Y' }}</td>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue