diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 838e20dfd4d7ab94ad5784b6754d759fec070c69..d36378a6f84e89bc70e581662f6226b6dd78a777 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -24,6 +24,7 @@ Added * [Dev] Options for filtering and sorting of GraphQL queries at the server. * [Dev] Managed models for instances handled by other apps. * [Dev] Upload slot sytem for out-of-band uploads in GraphQL clients +* Generic endpoint for retrieving objects as JSON Changed ~~~~~~~ diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py index 7331e7d4ca3777e81eae1d6374c5a02e85bc818f..c6c19af0a5f58a105377bb2a36502b562056739d 100644 --- a/aleksis/core/mixins.py +++ b/aleksis/core/mixins.py @@ -608,6 +608,11 @@ class RegistryObject: return cls.registered_objects_dict.get(name) +class ObjectAuthenticator(RegistryObject): + def authenticate(self, request, obj): + raise NotImplementedError() + + class CalendarEventMixin(RegistryObject): """Mixin for calendar feeds. @@ -755,3 +760,6 @@ class CalendarEventMixin(RegistryObject): def valid_feed_names(cls): """Return a list of valid feed names.""" return [feed.name for feed in cls.valid_feeds] + + def get_object_by_name(cls, name): + return cls.registered_objects_dict.get(name) diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py index 774e17ce5d0f221bed8fbc85b1c06a7d78511e8a..339a4bc2fee1c0546e62ca452793dfc7682e3aea 100644 --- a/aleksis/core/urls.py +++ b/aleksis/core/urls.py @@ -42,6 +42,21 @@ urlpatterns = [ ), path("oauth/", include("oauth2_provider.urls", namespace="oauth2_provider")), path("system_status/", views.SystemStatusAPIView.as_view(), name="system_status_api"), + path( + "o/<str:app_label>/<str:model>/<int:pk>/", + views.ObjectRepresentationView.as_view(), + name="object_representation_with_pk", + ), + path( + "o/<str:app_label>/<str:model>/", + views.ObjectRepresentationView.as_view(), + name="object_representation_with_model", + ), + path( + "o/", + views.ObjectRepresentationView.as_view(), + name="object_representation_anonymous", + ), path("", include("django_prometheus.urls")), path( "django/", diff --git a/aleksis/core/views.py b/aleksis/core/views.py index fa2404922d8a287c863151a6290718210a1c9bf6..f9e21e5b57472664e96bded0ebb471a11aff014b 100644 --- a/aleksis/core/views.py +++ b/aleksis/core/views.py @@ -101,6 +101,7 @@ from .mixins import ( AdvancedDeleteView, AdvancedEditView, CalendarEventMixin, + ObjectAuthenticator, SuccessNextMixin, ) from .models import ( @@ -1556,6 +1557,78 @@ class LoggingGraphQLView(GraphQLView): return result +class ObjectRepresentationView(View): + """View with unique URL to get a JSON representation of an object.""" + + def get_model(self, request: HttpRequest, app_label: str, model: str): + """Get the model by app label and model name.""" + try: + return apps.get_model(app_label, model) + except LookupError: + raise Http404() + + def get_object(self, request: HttpRequest, app_label: str, model: str, pk: int): + """Get the object by app label, model name and primary key.""" + if getattr(self, "model", None) is None: + self.model = self.get_model(request, app_label, model) + + try: + return self.model.objects.get(pk=pk) + except self.model.DoesNotExist: + raise Http404() + + def get( + self, + request: HttpRequest, + app_label: Optional[str] = None, + model: Optional[str] = None, + pk: Optional[int] = None, + *args, + **kwargs, + ) -> HttpResponse: + if app_label and model: + self.model = self.get_model(request, app_label, model) + else: + self.model = None + + if app_label and model and pk: + self.object = self.get_object(request, app_label, model, pk) + else: + self.object = None + + authenticators = request.GET.get("authenticators", "").split(",") + if authenticators == [""]: + authenticators = list(ObjectAuthenticator.registered_objects_dict.keys()) + self.authenticate(request, authenticators) + + if hasattr(self.object, "get_json"): + res = self.object.get_json(request) + else: + res = {"id": self.object.id} + res["_meta"] = { + "model": self.object._meta.model_name, + "app": self.object._meta.app_label, + } + + return JsonResponse(res) + + def authenticate(self, request: HttpRequest, authenticators: list[str]) -> bool: + """Authenticate the request against the given authenticators.""" + for authenticator in authenticators: + authenticator_class = ObjectAuthenticator.get_object_by_name(authenticator) + if not authenticator_class: + continue + obj = authenticator_class().authenticate(request, self.object) + if obj: + if self.object is None: + self.object = obj + elif obj != self.object: + raise BadRequest("Ambiguous objects identified") + return True + + raise PermissionDenied() + + class ICalFeedView(PermissionRequiredMixin, View): """View to generate an iCal feed for a calendar.""" diff --git a/aleksis/core/vite.config.js b/aleksis/core/vite.config.js index 0d341f37bba0b64b50d9281e7d3fe2f93540256c..772fa5f27eccf8f93f1051cd51f8aaeb6e60535c 100644 --- a/aleksis/core/vite.config.js +++ b/aleksis/core/vite.config.js @@ -259,7 +259,7 @@ export default defineConfig({ directoryIndex: null, navigateFallbackAllowlist: [ new RegExp( - "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$" + "^/(?!(django|admin|graphql|__icons__|oauth/authorize|o))[^.]*$" ), ], additionalManifestEntries: [ @@ -274,7 +274,7 @@ export default defineConfig({ runtimeCaching: [ { urlPattern: new RegExp( - "^/(?!(django|admin|graphql|__icons__|oauth/authorize))[^.]*$" + "^/(?!(django|admin|graphql|__icons__|oauth/authorize|o))[^.]*$" ), handler: "CacheFirst", },