From afea9cf055fb56b1d56bea5d14b2c1cb813cecae Mon Sep 17 00:00:00 2001 From: tomi-rli Date: Wed, 1 Jul 2026 00:46:20 +0200 Subject: [PATCH 1/3] Improve OpenAPI documentation grouping --- api/serializers.py | 13 ++- api/urls.py | 23 ++++ api/views.py | 250 +++++++++++++++++++++++++++++++++++------ oeplatform/settings.py | 10 +- requirements.txt | 1 + 5 files changed, 257 insertions(+), 40 deletions(-) diff --git a/api/serializers.py b/api/serializers.py index 5fb681526..eac02235d 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -173,10 +173,15 @@ class Meta: class DatasetCreateSerializer(serializers.Serializer): - name = serializers.CharField() - title = serializers.CharField() - description = serializers.CharField() - at_id = serializers.URLField(required=False) + name = serializers.CharField(help_text="Name of the dataset") + title = serializers.CharField( + help_text="Anzeigename des Datensatzes, z. B. 'Wind Power Dataset Germany'" + ) + description = serializers.CharField(help_text="Kurze Beschreibung des Datensatzes") + at_id = serializers.URLField( + required=False, + help_text="Optional: Persistenter Identifier oder URL für den Datensatz", + ) class DatasetAssignTablesSerializer(serializers.Serializer): diff --git a/api/urls.py b/api/urls.py index b7a9a74c2..9a10eb571 100644 --- a/api/urls.py +++ b/api/urls.py @@ -15,6 +15,11 @@ """ # noqa: 501 from django.urls import include, path, re_path +from drf_spectacular.views import ( + SpectacularAPIView, + SpectacularRedocView, + SpectacularSwaggerView, +) from api.views import ( AdvancedCloseAllAPIView, @@ -258,6 +263,24 @@ ] urlpatterns_v0 = [ + # OpenAPI Schema (JSON) + path( + "schema/", + SpectacularAPIView.as_view(), + name="openapi-schema", + ), + # Swagger UI + path( + "open-api/", + SpectacularSwaggerView.as_view(url_name="api:openapi-schema"), + name="swagger-ui", + ), + # Redoc UI + path( + "redoc/", + SpectacularRedocView.as_view(url_name="api:openapi-schema"), + name="redoc", + ), # PROBLEM: redirect does not work with POST/PUT/..., only GET # so we cannot redirect re_path( # legacy API url for tables diff --git a/api/views.py b/api/views.py index 9c70715c5..45f8b0d41 100644 --- a/api/views.py +++ b/api/views.py @@ -51,6 +51,13 @@ from django.shortcuts import get_object_or_404 from django.utils.decorators import method_decorator from django.views.decorators.cache import never_cache +from drf_spectacular.utils import ( + OpenApiExample, + OpenApiParameter, + OpenApiResponse, + extend_schema, + extend_schema_view, +) from oemetadata.latest.example import OEMETADATA_LATEST_EXAMPLE from oemetadata.latest.template import OEMETADATA_LATEST_TEMPLATE from rest_framework import generics, status @@ -197,6 +204,10 @@ ) +@extend_schema_view( + get=extend_schema(tags=["Schema: Meta"]), + post=extend_schema(tags=["Schema: Meta"]), +) class TableMetadataAPIView(APIView): """ Important note: @@ -254,6 +265,34 @@ def post(self, request: Request, table: str) -> JsonLikeResponse: raise APIError(error) +@extend_schema_view( + post=extend_schema( + summary="Create dataset", + description="Creates a new dataset.", + request=DatasetCreateSerializer, + responses={}, + examples=[ + OpenApiExample( + "Dataset Example", + summary="Example request body for " "creating a dataset", + description=( + "Use this JSON object to create a new dataset. " + "The `at_id` field is optional and can contain " + "a persistent identifier." + ), + value={ + "name": "test_dataset", + "title": "Wind Power Dataset Germany", + "description": ( + "Contains hourly wind generation " "data for Germany." + ), + "at_id": "https://example.org/datasets/test_dataset", + }, + request_only=True, + ) + ], + ) +) class DatasetsListCreate(generics.ListCreateAPIView): queryset = Dataset.objects.all() @@ -275,6 +314,13 @@ def create(self, request, *args, **kwargs): ) +@extend_schema_view( + get=extend_schema( + summary="List dataset resources", + description="Returns the tables/resources that belong to a dataset.", + responses=DatasetResourceSerializer(many=True), + ) +) class DatasetsListResources(generics.ListAPIView): serializer_class = DatasetResourceSerializer @@ -284,6 +330,37 @@ def get_queryset(self): return dataset.tables.all() +@extend_schema_view( + get=extend_schema( + summary="Get dataset", + description="Returns metadata for a single dataset.", + responses=DatasetReadSerializer, + ), + put=extend_schema( + summary="Update dataset", + description="Updates metadata for an existing dataset.", + request=DatasetCreateSerializer, + responses={200: OpenApiResponse(description="Dataset updated")}, + examples=[ + OpenApiExample( + "Update dataset example", + summary="Example request body for updating a dataset", + value={ + "name": "test_dataset", + "title": "Updated Wind Power Dataset Germany", + "description": "Updated description with more details.", + "at_id": "https://example.org/datasets/test_dataset", + }, + request_only=True, + ) + ], + ), + delete=extend_schema( + summary="Delete dataset", + description="Deletes the specified dataset.", + responses={204: OpenApiResponse(description="Dataset deleted")}, + ), +) class DatasetManager(APIView): """ View to retrieve, update, or delete a single dataset's metadata. @@ -308,11 +385,55 @@ def delete(self, request, dataset_name): dataset = get_object_or_404(Dataset, name=dataset_name) dataset.delete() return Response( - {"message": "Dataset deleted"}, status=status.HTTP_204_NO_CONTENT + {"message": "Dataset deleted"}, + status=status.HTTP_204_NO_CONTENT, ) class AssignDatasetTables(APIView): + """ + Assign existing OEP tables to an existing dataset. + """ + + @extend_schema( + summary="Assign tables to dataset", + description=( + "Assigns existing OEP tables to an existing dataset. " + "The dataset must already exist and the referenced " + "tables must already exist. After assignment, the " + "dataset resources are updated from the table metadata." + ), + parameters=[ + OpenApiParameter( + name="dataset_name", + type=str, + location=OpenApiParameter.PATH, + required=True, + description=( + "Name of the dataset to which the tables should be assigned. " + "Example: `test_dataset`." + ), + ) + ], + request=DatasetAssignTablesSerializer, + responses={ + 200: OpenApiResponse(description="Tables were assigned to the dataset."), + 404: OpenApiResponse(description="Dataset was not found."), + }, + examples=[ + OpenApiExample( + "Assign tables example", + summary="Example request body for assigning tables", + value={ + "tables": [ + {"name": "germany_wind_hourly"}, + {"name": "germany_wind_daily"}, + ] + }, + request_only=True, + ) + ], + ) def post(self, request, dataset_name): serializer = DatasetAssignTablesSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -1282,6 +1403,7 @@ def get(self, request: Request) -> JsonLikeResponse: return Response(data, status=status.HTTP_200_OK) +@extend_schema_view(post=extend_schema(tags=["Advanced: Cursor"])) class AdvancedFetchAPIView(APIView): @api_exception def post(self, request: Request, fetchtype) -> JsonLikeResponse: @@ -1315,6 +1437,7 @@ def transform_row(self, row): ) +@extend_schema_view(get=extend_schema(tags=["Advanced: Connection"])) class AdvancedCloseAllAPIView(LoginRequiredMixin, APIView): @api_exception def get(self, request: Request) -> JsonLikeResponse: @@ -1322,38 +1445,95 @@ def get(self, request: Request) -> JsonLikeResponse: return JsonResponse({"message": "All connections closed"}) -AdvancedSearchAPIView = create_ajax_handler( - data_search, allow_cors=True, requires_cursor=True +AdvancedSearchAPIView = extend_schema_view(post=extend_schema(tags=["Advanced"]))( + create_ajax_handler(data_search, allow_cors=True, requires_cursor=True) +) +AdvancedInsertAPIView = extend_schema_view(post=extend_schema(tags=["Advanced"]))( + create_ajax_handler(data_insert, requires_cursor=True) +) +AdvancedDeleteAPIView = extend_schema_view(post=extend_schema(tags=["Advanced"]))( + create_ajax_handler(data_delete, requires_cursor=True) +) +AdvancedUpdateAPIView = extend_schema_view(post=extend_schema(tags=["Advanced"]))( + create_ajax_handler(data_update, requires_cursor=True) +) + + +AdvancedHasSchemaAPIView = extend_schema_view(post=extend_schema(tags=["Advanced"]))( + create_ajax_handler(has_schema) +) +AdvancedHasTableAPIView = extend_schema_view(post=extend_schema(tags=["Advanced"]))( + create_ajax_handler(has_table) +) +AdvancedGetSchemaNamesAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced"]) +)(create_ajax_handler(get_schema_names)) +AdvancedGetTableNamesAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced"]) +)(create_ajax_handler(get_table_names)) +AdvancedGetViewNamesAPIView = extend_schema_view(post=extend_schema(tags=["Advanced"]))( + create_ajax_handler(get_view_names) +) +AdvancedGetViewDefinitionAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced"]) +)(create_ajax_handler(get_view_definition)) +AdvancedGetColumnsAPIView = extend_schema_view(post=extend_schema(tags=["Advanced"]))( + create_ajax_handler(get_columns) +) +AdvancedGetPkConstraintAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced"]) +)(create_ajax_handler(get_pk_constraint)) +AdvancedGetForeignKeysAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced"]) +)(create_ajax_handler(get_foreign_keys)) +AdvancedGetIndexesAPIView = extend_schema_view(post=extend_schema(tags=["Advanced"]))( + create_ajax_handler(get_indexes) ) -AdvancedInsertAPIView = create_ajax_handler(data_insert, requires_cursor=True) -AdvancedDeleteAPIView = create_ajax_handler(data_delete, requires_cursor=True) -AdvancedUpdateAPIView = create_ajax_handler(data_update, requires_cursor=True) - -AdvancedHasSchemaAPIView = create_ajax_handler(has_schema) -AdvancedHasTableAPIView = create_ajax_handler(has_table) -AdvancedGetSchemaNamesAPIView = create_ajax_handler(get_schema_names) -AdvancedGetTableNamesAPIView = create_ajax_handler(get_table_names) -AdvancedGetViewNamesAPIView = create_ajax_handler(get_view_names) -AdvancedGetViewDefinitionAPIView = create_ajax_handler(get_view_definition) -AdvancedGetColumnsAPIView = create_ajax_handler(get_columns) -AdvancedGetPkConstraintAPIView = create_ajax_handler(get_pk_constraint) -AdvancedGetForeignKeysAPIView = create_ajax_handler(get_foreign_keys) -AdvancedGetIndexesAPIView = create_ajax_handler(get_indexes) -AdvancedGetUniqueConstraintsAPIView = create_ajax_handler(get_unique_constraints) - -AdvancedConnectionOpenAPIView = create_ajax_handler(open_raw_connection) -AdvancedConnectionCloseAPIView = create_ajax_handler(close_raw_connection) -AdvancedConnectionCommitAPIView = create_ajax_handler(commit_raw_connection) -AdvancedConnectionRollbackAPIView = create_ajax_handler(rollback_raw_connection) - -AdvancedCursorOpenAPIView = create_ajax_handler(open_cursor) -AdvancedCursorCloseAPIView = create_ajax_handler(close_cursor) -AdvancedCursorFetchOneAPIView = create_ajax_handler(fetchone) - -AdvancedSetIsolationLevelAPIView = create_ajax_handler(set_isolation_level) -AdvancedGetIsolationLevelAPIView = create_ajax_handler(get_isolation_level) -AdvancedDoBeginTwophaseAPIView = create_ajax_handler(do_begin_twophase) -AdvancedDoPrepareTwophaseAPIView = create_ajax_handler(do_prepare_twophase) -AdvancedDoRollbackTwophaseAPIView = create_ajax_handler(do_rollback_twophase) -AdvancedDoCommitTwophaseAPIView = create_ajax_handler(do_commit_twophase) -AdvancedDoRecoverTwophaseAPIView = create_ajax_handler(do_recover_twophase) +AdvancedGetUniqueConstraintsAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced"]) +)(create_ajax_handler(get_unique_constraints)) + +AdvancedConnectionOpenAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Connection"]) +)(create_ajax_handler(open_raw_connection)) +AdvancedConnectionCloseAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Connection"]) +)(create_ajax_handler(close_raw_connection)) +AdvancedConnectionCommitAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Connection"]) +)(create_ajax_handler(commit_raw_connection)) +AdvancedConnectionRollbackAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Connection"]) +)(create_ajax_handler(rollback_raw_connection)) + +AdvancedCursorOpenAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Cursor"]) +)(create_ajax_handler(open_cursor)) +AdvancedCursorCloseAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Cursor"]) +)(create_ajax_handler(close_cursor)) +AdvancedCursorFetchOneAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Cursor"]) +)(create_ajax_handler(fetchone)) + +AdvancedSetIsolationLevelAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced"]) +)(create_ajax_handler(set_isolation_level)) +AdvancedGetIsolationLevelAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced"]) +)(create_ajax_handler(get_isolation_level)) +AdvancedDoBeginTwophaseAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Two phase"]) +)(create_ajax_handler(do_begin_twophase)) +AdvancedDoPrepareTwophaseAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Two phase"]) +)(create_ajax_handler(do_prepare_twophase)) +AdvancedDoRollbackTwophaseAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Two phase"]) +)(create_ajax_handler(do_rollback_twophase)) +AdvancedDoCommitTwophaseAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Two phase"]) +)(create_ajax_handler(do_commit_twophase)) +AdvancedDoRecoverTwophaseAPIView = extend_schema_view( + post=extend_schema(tags=["Advanced: Two phase"]) +)(create_ajax_handler(do_recover_twophase)) diff --git a/oeplatform/settings.py b/oeplatform/settings.py index 56652b5b0..32142026c 100644 --- a/oeplatform/settings.py +++ b/oeplatform/settings.py @@ -165,6 +165,7 @@ "owlready2", "compressor", "oekg", + "drf_spectacular", ) MIDDLEWARE = ( @@ -362,7 +363,14 @@ "rest_framework.authentication.BasicAuthentication", "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.TokenAuthentication", - ) + ), + # Use drf-spectacular's AutoSchema for generating OpenAPI schema + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", +} +SPECTACULAR_SETTINGS = { + "TITLE": "Open Energy Platform API", + "DESCRIPTION": "OpenAPI schema for the Open Energy Platform REST API.", + "VERSION": "v0", } AUTHENTICATION_BACKENDS = [ diff --git a/requirements.txt b/requirements.txt index c8b0c664d..a8720266a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -39,6 +39,7 @@ requests==2.33.0 psycopg2-binary==2.9.9 webcolors==1.13 djangorestframework==3.15.2 +drf-spectacular==0.28.0 django-axes==5.41.1 Shapely==1.8.5.post1 # In python v3.12 use at least Shapely==2.0.0 markdown2==2.4.13 From f69e7cfaff489a2a88104968b72a35edcf88c03e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:14:19 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- api/serializers.py | 1 - api/views.py | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/api/serializers.py b/api/serializers.py index 9b471ed70..ce4005f7c 100644 --- a/api/serializers.py +++ b/api/serializers.py @@ -203,7 +203,6 @@ class DatasetUpdateSerializer(serializers.Serializer): at_id = serializers.URLField(required=False) - class DatasetAssignTablesSerializer(serializers.Serializer): tables = serializers.ListField( child=serializers.DictField(child=serializers.CharField()), min_length=1 diff --git a/api/views.py b/api/views.py index 5e452c03d..00cc40e7d 100644 --- a/api/views.py +++ b/api/views.py @@ -494,6 +494,7 @@ def post(self, request, dataset_name): serializer.is_valid(raise_exception=True) table_refs = serializer.validated_data["tables"] + permission_classes = [IsAuthenticated] def post(self, request, dataset_name): From f539d41b3e4d2200902876ec32edda320ae711d4 Mon Sep 17 00:00:00 2001 From: tomi-rli Date: Mon, 3 Aug 2026 18:41:33 +0200 Subject: [PATCH 3/3] fix merge conflict --- api/views.py | 66 ++++++++++++++++++++++++---------------------------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/api/views.py b/api/views.py index 00cc40e7d..f9cf42884 100644 --- a/api/views.py +++ b/api/views.py @@ -277,34 +277,6 @@ def post(self, request: Request, table: str) -> JsonLikeResponse: raise APIError(error) -@extend_schema_view( - post=extend_schema( - summary="Create dataset", - description="Creates a new dataset.", - request=DatasetCreateSerializer, - responses={}, - examples=[ - OpenApiExample( - "Dataset Example", - summary="Example request body for " "creating a dataset", - description=( - "Use this JSON object to create a new dataset. " - "The `at_id` field is optional and can contain " - "a persistent identifier." - ), - value={ - "name": "test_dataset", - "title": "Wind Power Dataset Germany", - "description": ( - "Contains hourly wind generation " "data for Germany." - ), - "at_id": "https://example.org/datasets/test_dataset", - }, - request_only=True, - ) - ], - ) -) def assert_dataset_ownership(user, dataset: Dataset) -> None: """Datasets are creator-owned: only the creator may modify one.""" if dataset.creator is None or dataset.creator != user: @@ -333,6 +305,34 @@ def load_owned_dataset_from_request(request, dataset_name: str): return dataset, serializer.validated_data["tables"] +@extend_schema_view( + post=extend_schema( + summary="Create dataset", + description="Creates a new dataset.", + request=DatasetCreateSerializer, + responses={}, + examples=[ + OpenApiExample( + "Dataset Example", + summary="Example request body for " "creating a dataset", + description=( + "Use this JSON object to create a new dataset. " + "The `at_id` field is optional and can contain " + "a persistent identifier." + ), + value={ + "name": "test_dataset", + "title": "Wind Power Dataset Germany", + "description": ( + "Contains hourly wind generation " "data for Germany." + ), + "at_id": "https://example.org/datasets/test_dataset", + }, + request_only=True, + ) + ], + ) +) class DatasetsListCreate(generics.ListCreateAPIView): queryset = Dataset.objects.prefetch_related("tables") permission_classes = [IsAuthenticatedOrReadOnly] @@ -450,6 +450,8 @@ class AssignDatasetTables(APIView): Assign existing OEP tables to an existing dataset. """ + permission_classes = [IsAuthenticated] + @extend_schema( summary="Assign tables to dataset", description=( @@ -489,14 +491,6 @@ class AssignDatasetTables(APIView): ) ], ) - def post(self, request, dataset_name): - serializer = DatasetAssignTablesSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - table_refs = serializer.validated_data["tables"] - - permission_classes = [IsAuthenticated] - def post(self, request, dataset_name): dataset, table_refs = load_owned_dataset_from_request(request, dataset_name) if table_refs is None: