From a2f8f0076bdfd8a525e2b20ec910caed3eebf157 Mon Sep 17 00:00:00 2001 From: Jeroen Dekkers Date: Mon, 28 Oct 2024 10:40:31 +0100 Subject: [PATCH] Add REST API to list report and download pdf report (#3689) Co-authored-by: Jan Klopper Co-authored-by: stephanie0x00 <9821756+stephanie0x00@users.noreply.github.com> --- rocky/account/mixins.py | 64 ++++++++++++++++++++++++++++++++++++ rocky/reports/serializers.py | 19 +++++++++++ rocky/reports/viewsets.py | 43 ++++++++++++++++++++++++ rocky/rocky/settings.py | 2 ++ rocky/rocky/urls.py | 2 ++ rocky/tools/viewsets.py | 5 +++ 6 files changed, 135 insertions(+) create mode 100644 rocky/reports/serializers.py create mode 100644 rocky/reports/viewsets.py diff --git a/rocky/account/mixins.py b/rocky/account/mixins.py index 320d223ef8c..19eb734087a 100644 --- a/rocky/account/mixins.py +++ b/rocky/account/mixins.py @@ -1,4 +1,5 @@ from datetime import datetime, timezone +from functools import cached_property import structlog.contextvars from django.conf import settings @@ -8,6 +9,8 @@ from django.http import Http404 from django.utils.translation import gettext_lazy as _ from django.views import View +from rest_framework.exceptions import ValidationError +from rest_framework.request import Request from tools.models import Indemnification, Organization, OrganizationMember from octopoes.connector.octopoes import OctopoesAPIConnector @@ -203,3 +206,64 @@ class OrganizationPermissionRequiredMixin(PermissionRequiredMixin): def has_permission(self) -> bool: perms = self.get_permission_required() return self.organization_member.has_perms(perms) + + +class OrganizationAPIMixin: + request: Request + + def get_organization(self, field: str, value: str) -> Organization: + lookup_param = {field: value} + try: + organization = Organization.objects.get(**lookup_param) + except Organization.DoesNotExist as e: + raise Http404(f"Organization with {field} {value} does not exist") from e + + if self.request.user.has_perm("tools.can_access_all_organizations"): + return organization + + try: + organization_member = OrganizationMember.objects.get(user=self.request.user, organization=organization) + except OrganizationMember.DoesNotExist as e: + raise Http404(f"Organization with {field} {value} does not exist") from e + + if organization_member.blocked: + raise PermissionDenied() + + return organization + + @cached_property + def organization(self) -> Organization: + try: + organization_id = self.request.query_params["organization_id"] + except KeyError: + pass + else: + return self.get_organization("id", organization_id) + + try: + organization_code = self.request.query_params["organization_code"] + except KeyError as e: + raise ValidationError("Missing organization_id or organization_code query parameter") from e + else: + return self.get_organization("code", organization_code) + + @cached_property + def octopoes_api_connector(self) -> OctopoesAPIConnector: + return OctopoesAPIConnector(settings.OCTOPOES_API, self.organization.code) + + @cached_property + def valid_time(self) -> datetime: + try: + valid_time = self.request.query_params["valid_time"] + except KeyError: + return datetime.now(timezone.utc) + else: + try: + ret = datetime.fromisoformat(valid_time) + except ValueError: + raise ValidationError(f"Wrong format for valid_time: {valid_time}") + + if not ret.tzinfo: + ret = ret.replace(tzinfo=timezone.utc) + + return ret diff --git a/rocky/reports/serializers.py b/rocky/reports/serializers.py new file mode 100644 index 00000000000..da018567aed --- /dev/null +++ b/rocky/reports/serializers.py @@ -0,0 +1,19 @@ +from rest_framework import serializers + +from rocky.views.mixins import HydratedReport + + +class ReportSerializer(serializers.BaseSerializer): + def to_representation(self, instance): + if isinstance(instance, HydratedReport): + report = instance.parent_report + else: + report = instance + return { + "id": report.report_id, + "valid_time": report.observed_at, + "name": report.name, + "report_type": report.report_type, + "generated_at": report.date_generated, + "intput_oois": report.input_oois, + } diff --git a/rocky/reports/viewsets.py b/rocky/reports/viewsets.py new file mode 100644 index 00000000000..854857f78cb --- /dev/null +++ b/rocky/reports/viewsets.py @@ -0,0 +1,43 @@ +from account.mixins import OrganizationAPIMixin +from django.http import HttpResponseRedirect +from django.urls import reverse +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from structlog import get_logger +from tools.view_helpers import url_with_querystring + +from octopoes.models import Reference +from reports.serializers import ReportSerializer +from rocky.views.mixins import ReportList + +logger = get_logger(__name__) + + +class ReportViewSet(OrganizationAPIMixin, viewsets.ModelViewSet): + # There are no extra permissions needed to view reports, so IsAuthenticated + # is enough for list/retrieve. OrganizationAPIMixin will check if the user + # is a member of the requested organization. + permission_classes = [IsAuthenticated] + serializer_class = ReportSerializer + + def get_queryset(self): + return ReportList(self.octopoes_api_connector, self.valid_time) + + def get_object(self): + pk = self.kwargs["pk"] + + return self.octopoes_api_connector.get(Reference.from_str(f"Report|{pk}"), valid_time=self.valid_time) + + @action(detail=True) + def pdf(self, request, pk): + report_ooi_id = f"Report|{pk}" + + url = url_with_querystring( + reverse("view_report_pdf", kwargs={"organization_code": self.organization.code}), + True, + report_id=report_ooi_id, + observed_at=self.valid_time.isoformat(), + ) + + return HttpResponseRedirect(redirect_to=url) diff --git a/rocky/rocky/settings.py b/rocky/rocky/settings.py index d7d7f5a8841..3ffddf72025 100644 --- a/rocky/rocky/settings.py +++ b/rocky/rocky/settings.py @@ -431,6 +431,8 @@ def immutable_file_test(path, url): "DEFAULT_AUTHENTICATION_CLASSES": DEFAULT_AUTHENTICATION_CLASSES, "DEFAULT_PERMISSION_CLASSES": ["rocky.permissions.KATModelPermissions"], "DEFAULT_RENDERER_CLASSES": DEFAULT_RENDERER_CLASSES, + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination", + "PAGE_SIZE": 100, "EXCEPTION_HANDLER": "drf_standardized_errors.handler.exception_handler", } diff --git a/rocky/rocky/urls.py b/rocky/rocky/urls.py index 43dabc582a9..3f26ce7259b 100644 --- a/rocky/rocky/urls.py +++ b/rocky/rocky/urls.py @@ -2,6 +2,7 @@ from django.contrib import admin from django.urls import include, path from django.views.generic.base import TemplateView +from reports.viewsets import ReportViewSet from rest_framework import routers from tools.viewsets import OrganizationViewSet from two_factor.urls import urlpatterns as tf_urls @@ -54,6 +55,7 @@ router = routers.SimpleRouter() router.register(r"organization", OrganizationViewSet) +router.register(r"report", ReportViewSet, basename="report") urlpatterns = [ path("i18n/", include("django.conf.urls.i18n")), diff --git a/rocky/tools/viewsets.py b/rocky/tools/viewsets.py index 429d70df184..4f916fe534d 100644 --- a/rocky/tools/viewsets.py +++ b/rocky/tools/viewsets.py @@ -17,6 +17,11 @@ class OrganizationViewSet(viewsets.ModelViewSet): queryset = Organization.objects.all() serializer_class = OrganizationSerializer + # When we created this viewset we didn't have pagination enabled in the + # django-rest-framework settings. Enabling it afterwards would cause the API + # to change in an incompatible way, we should enable this when we introduce + # a new API version. + pagination_class = None # Unfortunately django-rest-framework doesn't have support for create only # fields so we have to change the serializer class depending on the request