From 0d6038bf510b3779275c3d98bc23b60b91fc1c86 Mon Sep 17 00:00:00 2001 From: Konrad Mohrfeldt <konrad.mohrfeldt@farbdev.org> Date: Sat, 27 Jul 2024 15:34:01 +0200 Subject: [PATCH] feat: add debug application-state endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This new endpoint allows clients to easily purge steering’s application state. It is primarily designed for use in integration tests as outlined in aura/aura#387. The endpoint is available in debug mode so that developers can inspect the schema. However, any request to it is denied unless the OPERATING_MODE environment variable is set to "tests". This is a precaution for administrators that use the debug mode to analyze application issues in production. --- program/models.py | 70 ++++++++++++++++++++++++++++++++++++++++++ program/serializers.py | 32 +++++++++++++++++++ program/views.py | 48 ++++++++++++++++++++++++++++- steering/settings.py | 5 +++ steering/urls.py | 8 +++++ 5 files changed, 162 insertions(+), 1 deletion(-) diff --git a/program/models.py b/program/models.py index 9898b108..87dfa525 100644 --- a/program/models.py +++ b/program/models.py @@ -17,8 +17,10 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. # +import collections import dataclasses import datetime +import typing import jsonschema from rest_framework.exceptions import ValidationError @@ -705,3 +707,71 @@ class ProgramEntry: return self.timeslot.playlist_id else: return self.show.default_playlist_id + + +class ApplicationStateManager: + categorized_models: typing.Mapping[str, list[typing.Type[models.Model]]] = { + "classifications": [ + Type, + Category, + Topic, + MusicFocus, + FundingCategory, + Language, + License, + LinkType, + RRule, + ], + "settings": [RadioSettings], + "auth": [CBA, User], + "media": [Image], + "program": [Note, TimeSlot, Schedule, Show], + } + + @property + def _models(self): + result = [] + for _models in self.categorized_models.values(): + result.extend(_models) + return result + + @property + def _model_map(self): + return {model._meta.label: model for model in self._models} + + @property + def model_category_choices(self): + return sorted(self.categorized_models.keys()) + + @property + def model_choices(self): + return sorted(self._model_map.keys()) + + def purge( + self, + model_category_names: typing.Iterable[str] | None = None, + model_names: typing.Iterable[str] | None = None, + invert_selection: bool = False, + ): + model_map = self._model_map + model_category_names = set(model_category_names or []) + model_names = set(model_names or []) + + selected_models: set[typing.Type[models.Model]] = set() + for category_name in model_category_names: + selected_models.update(self.categorized_models[category_name]) + for model_name in model_names: + selected_models.add(model_map[model_name]) + if invert_selection: + selected_models = set(self._models).difference(selected_models) + + # Some models may have dependent state and therefore need to be deleted in order. + ordered_selected_models = [model for model in self._models if model in selected_models] + deleted_model_count_map = collections.defaultdict(int) + for model in ordered_selected_models: + for model_path, count in model.objects.all().delete()[1].items(): + deleted_model_count_map[model_path] += count + return deleted_model_count_map + + +application_state_manager = ApplicationStateManager() diff --git a/program/serializers.py b/program/serializers.py index e34aa9b2..bbae2a63 100644 --- a/program/serializers.py +++ b/program/serializers.py @@ -52,6 +52,7 @@ from program.models import ( TimeSlot, Topic, Type, + application_state_manager, ) from program.typing import ( Logo, @@ -1399,3 +1400,34 @@ class CalendarSchemaSerializer(serializers.Serializer): episodes = CalendarEpisodeSerializer(many=True) licenses = LicenseSerializer(many=True) link_types = LinkTypeSerializer(many=True) + + +class ApplicationStatePurgeSerializer(serializers.Serializer): + @staticmethod + def _render_model_category_definitions(): + yield "<dl>" + for category_name, models in application_state_manager.categorized_models.items(): + model_names = ", ".join(sorted(model._meta.label for model in models)) + yield f"<dt>{category_name}</dt>" + yield f"<dd>{model_names}</dd>" + yield "</dl>" + + models = serializers.MultipleChoiceField( + choices=application_state_manager.model_choices, default=set() + ) + model_categories = serializers.MultipleChoiceField( + choices=application_state_manager.model_category_choices, + default=set(), + help_text=( + "Selects multiple models by their categorization. " + "Models included in the categories are: " + f"{''.join(_render_model_category_definitions())}" + ), + ) + invert_selection = serializers.BooleanField( + default=False, + help_text=( + "Inverts the model selection that is selected through other filters. " + "Selects all models if set to true and no other filters have been set." + ), + ) diff --git a/program/views.py b/program/views.py index c970cbc4..a03556f6 100644 --- a/program/views.py +++ b/program/views.py @@ -33,8 +33,9 @@ from drf_spectacular.utils import ( ) from rest_framework import decorators from rest_framework import filters as drf_filters -from rest_framework import mixins, permissions, status, viewsets +from rest_framework import mixins, permissions, status, views, viewsets from rest_framework.pagination import LimitOffsetPagination +from rest_framework.request import Request from rest_framework.response import Response from django.conf import settings @@ -62,8 +63,10 @@ from program.models import ( TimeSlot, Topic, Type, + application_state_manager, ) from program.serializers import ( + ApplicationStatePurgeSerializer, BasicProgramEntrySerializer, CalendarSchemaSerializer, CategorySerializer, @@ -1281,3 +1284,46 @@ class APILicenseViewSet(viewsets.ModelViewSet): class APIRadioSettingsViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): queryset = RadioSettings.objects.all() serializer_class = RadioSettingsSerializer + + +class TestOperationPermission(permissions.BasePermission): + def has_permission(self, request, view): + return settings.OPERATION_MODE == "tests" + + def has_object_permission(self, request, view, obj): + return self.has_permission(request, view) + + +class TestOperationViewMixin: + authentication_classes = [] + permission_classes = [TestOperationPermission] + + +class APIApplicationStateView(TestOperationViewMixin, views.APIView): + @extend_schema( + description=( + "Purges any data of the selected models and " + "returns the deleted count for each of them." + ), + parameters=[ApplicationStatePurgeSerializer], + responses={200: dict[str, int]}, + examples=[ + OpenApiExample( + "Example entry", + value={ + "program.Show": 6, + "program.TimeSlot": 38, + }, + ), + ], + tags=["debug"], + ) + def delete(self, request: Request, *args, **kwargs): + params = ApplicationStatePurgeSerializer(data=request.query_params) + params.is_valid(raise_exception=True) + deleted = application_state_manager.purge( + model_category_names=set(params.validated_data["model_categories"]), + model_names=set(params.validated_data["models"]), + invert_selection=params.validated_data["invert_selection"], + ) + return Response(status=status.HTTP_200_OK, data=deleted) diff --git a/steering/settings.py b/steering/settings.py index 773bf3ca..c77936fb 100644 --- a/steering/settings.py +++ b/steering/settings.py @@ -1,6 +1,7 @@ import os import sys from pathlib import Path +from typing import Literal, cast import ldap from corsheaders.defaults import default_headers @@ -218,6 +219,10 @@ SITE_URL = f"{AURA_PROTO}://{AURA_HOST}:{PORT}" if PORT else f"{AURA_PROTO}://{A if AURA_PROTO == "https": CSRF_TRUSTED_ORIGINS = [f"{AURA_PROTO}://{AURA_HOST}"] +OPERATION_MODE_TYPE = Literal["default", "tests"] +OPERATION_MODE: OPERATION_MODE_TYPE +OPERATION_MODE = cast(OPERATION_MODE_TYPE, os.getenv("OPERATION_MODE", default="default")) + LOGGING = { "version": 1, "disable_existing_loggers": False, diff --git a/steering/urls.py b/steering/urls.py index ccf2a0d0..d863f584 100644 --- a/steering/urls.py +++ b/steering/urls.py @@ -26,6 +26,7 @@ from django.conf.urls.static import static from django.contrib import admin from django.urls import include, path from program.views import ( + APIApplicationStateView, APICategoryViewSet, APIFundingCategoryViewSet, APIImageViewSet, @@ -83,3 +84,10 @@ urlpatterns = [ ), path("admin/", admin.site.urls), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + +if settings.DEBUG or settings.OPERATION_MODE == "tests": + # This endpoint is only usable with OPERATION_MODE == "tests" + # but we include it in DEBUG mode so that developers can inspect the schema. + urlpatterns.append( + path("api/v1/debug/application-state/", APIApplicationStateView.as_view()), + ) -- GitLab