ixg_platform/apps/buyer/views.py
ixgadmin 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

174 lines
6.3 KiB
Python

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.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
class BuyerRequiredMixin(UserPassesTestMixin):
def test_func(self):
user = self.request.user
return user.is_authenticated and (user.role in ("buyer", "admin") or user.is_superuser)
class DashboardView(BuyerRequiredMixin, TemplateView):
template_name = "portal/dashboard.html"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
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]
ctx["active_shipments"] = Shipment.objects.exclude(status="delivered").count()
ctx["subscription"] = Subscription.objects.filter(user=self.request.user).first()
return ctx
class BatchListView(BuyerRequiredMixin, ListView):
model = ProcessingBatch
template_name = "portal/batch_list.html"
context_object_name = "batches"
paginate_by = 25
def get_queryset(self):
qs = ProcessingBatch.objects.all()
status = self.request.GET.get("status")
q = self.request.GET.get("q")
if status:
qs = qs.filter(status=status)
if q:
qs = qs.filter(batch_reference__icontains=q)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["status_choices"] = ProcessingBatch.Status.choices
ctx["current_status"] = self.request.GET.get("status", "")
ctx["current_q"] = self.request.GET.get("q", "")
return ctx
class BatchDetailView(BuyerRequiredMixin, DetailView):
model = ProcessingBatch
template_name = "portal/batch_detail.html"
context_object_name = "batch"
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
b = self.object
ctx["shipments"] = Shipment.objects.filter(batch=b)
ctx["documents"] = Document.objects.filter(batch=b, visibility="buyer")
ctx["esg_reports"] = ESGFieldReport.objects.filter(batch=b)
return ctx
class DocumentListView(BuyerRequiredMixin, ListView):
model = Document
template_name = "portal/document_list.html"
context_object_name = "documents"
paginate_by = 25
def get_queryset(self):
qs = Document.objects.filter(visibility="buyer").select_related("batch", "uploaded_by")
doc_type = self.request.GET.get("doc_type")
if doc_type:
qs = qs.filter(doc_type=doc_type)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["doc_type_choices"] = Document.DocType.choices
ctx["current_doc_type"] = self.request.GET.get("doc_type", "")
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"
context_object_name = "sub"
def get_object(self):
return Subscription.objects.filter(user=self.request.user).first()
class ChatView(BuyerRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
from apps.ai.models import AgentConfig
user = self.request.user
if user.role in ("admin", "ops") or user.is_superuser:
ctx["agents"] = AgentConfig.objects.filter(is_active=True)
else:
ctx["agents"] = AgentConfig.objects.filter(is_active=True, user__isnull=True)
return ctx
template_name = "portal/chat.html"
class ChatEmbedView(BuyerRequiredMixin, TemplateView):
template_name = "portal/chat_embed.html"
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
response["X-Frame-Options"] = "ALLOWALL"
return response
class ComplianceView(BuyerRequiredMixin, TemplateView):
template_name = "portal/compliance.html"
def get_context_data(self, **kwargs):
from apps.compliance.models import DueDiligenceCase
from apps.documents.models import CertificateOfAnalysis
ctx = super().get_context_data(**kwargs)
ctx["approved_coas"] = CertificateOfAnalysis.objects.filter(
status=CertificateOfAnalysis.Status.PUBLISHED
).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"