mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-04-21 07:19:26 +00:00
Chore: Update API schema fields (#12611)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,12 @@ from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def api_schema():
|
||||
generator = SchemaGenerator()
|
||||
return generator.get_schema(request=None, public=True)
|
||||
|
||||
|
||||
class TestApiSchema(APITestCase):
|
||||
ENDPOINT = "/api/schema/"
|
||||
|
||||
@@ -70,26 +76,17 @@ class TestApiSchema(APITestCase):
|
||||
self.assertIn(action_method, advertised_methods)
|
||||
|
||||
|
||||
# ---- session-scoped fixture: generate schema once for all TestXxx classes ----
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def api_schema():
|
||||
generator = SchemaGenerator()
|
||||
return generator.get_schema(request=None, public=True)
|
||||
|
||||
|
||||
class TestTasksSummarySchema:
|
||||
"""tasks_summary_retrieve: response must be an array of TaskSummarySerializer."""
|
||||
|
||||
def test_summary_response_is_array(self, api_schema):
|
||||
def test_summary_response_is_array(self, api_schema: SchemaGenerator):
|
||||
op = api_schema["paths"]["/api/tasks/summary/"]["get"]
|
||||
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
assert resp_200["type"] == "array", (
|
||||
"tasks_summary_retrieve response must be type:array"
|
||||
)
|
||||
|
||||
def test_summary_items_have_total_count(self, api_schema):
|
||||
def test_summary_items_have_total_count(self, api_schema: SchemaGenerator):
|
||||
op = api_schema["paths"]["/api/tasks/summary/"]["get"]
|
||||
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
items = resp_200.get("items", {})
|
||||
@@ -107,14 +104,14 @@ class TestTasksSummarySchema:
|
||||
class TestTasksActiveSchema:
|
||||
"""tasks_active_retrieve: response must be an array of TaskSerializerV10."""
|
||||
|
||||
def test_active_response_is_array(self, api_schema):
|
||||
def test_active_response_is_array(self, api_schema: SchemaGenerator):
|
||||
op = api_schema["paths"]["/api/tasks/active/"]["get"]
|
||||
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
assert resp_200["type"] == "array", (
|
||||
"tasks_active_retrieve response must be type:array"
|
||||
)
|
||||
|
||||
def test_active_items_ref_named_schema(self, api_schema):
|
||||
def test_active_items_ref_named_schema(self, api_schema: SchemaGenerator):
|
||||
op = api_schema["paths"]["/api/tasks/active/"]["get"]
|
||||
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
items = resp_200.get("items", {})
|
||||
@@ -122,3 +119,129 @@ class TestTasksActiveSchema:
|
||||
component_name = ref.split("/")[-1] if ref else ""
|
||||
assert component_name, "items should be a $ref to a named schema"
|
||||
assert component_name in api_schema["components"]["schemas"]
|
||||
|
||||
|
||||
class TestMetadataSchema:
|
||||
"""Metadata component: array fields and optional archive fields."""
|
||||
|
||||
@pytest.mark.parametrize("field", ["original_metadata", "archive_metadata"])
|
||||
def test_metadata_field_is_array(self, api_schema: SchemaGenerator, field: str):
|
||||
props = api_schema["components"]["schemas"]["Metadata"]["properties"]
|
||||
assert props[field]["type"] == "array", (
|
||||
f"{field} should be type:array, not type:object"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("field", ["original_metadata", "archive_metadata"])
|
||||
def test_metadata_items_have_key_field(
|
||||
self,
|
||||
api_schema: SchemaGenerator,
|
||||
field: str,
|
||||
):
|
||||
props = api_schema["components"]["schemas"]["Metadata"]["properties"]
|
||||
items = props[field]["items"]
|
||||
ref = items.get("$ref", "")
|
||||
component_name = ref.split("/")[-1] if ref else ""
|
||||
if component_name:
|
||||
item_props = api_schema["components"]["schemas"][component_name][
|
||||
"properties"
|
||||
]
|
||||
else:
|
||||
item_props = items.get("properties", {})
|
||||
assert "key" in item_props
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field",
|
||||
[
|
||||
"archive_checksum",
|
||||
"archive_media_filename",
|
||||
"archive_size",
|
||||
"archive_metadata",
|
||||
],
|
||||
)
|
||||
def test_archive_field_not_required(self, api_schema, field):
|
||||
schema = api_schema["components"]["schemas"]["Metadata"]
|
||||
required = schema.get("required", [])
|
||||
assert field not in required
|
||||
props = schema["properties"]
|
||||
assert props[field].get("nullable") is True, (
|
||||
f"{field} should be nullable (allow_null=True)"
|
||||
)
|
||||
|
||||
|
||||
class TestStoragePathTestSchema:
|
||||
"""storage_paths_test_create: response must be a string, not a StoragePath object."""
|
||||
|
||||
def test_test_action_response_is_string(self, api_schema: SchemaGenerator):
|
||||
op = api_schema["paths"]["/api/storage_paths/test/"]["post"]
|
||||
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
assert resp_200.get("type") == "string", (
|
||||
"storage_paths_test_create 200 response must be type:string"
|
||||
)
|
||||
|
||||
def test_test_action_request_uses_storage_path_test_serializer(
|
||||
self,
|
||||
api_schema: SchemaGenerator,
|
||||
):
|
||||
op = api_schema["paths"]["/api/storage_paths/test/"]["post"]
|
||||
content = (
|
||||
op.get("requestBody", {}).get("content", {}).get("application/json", {})
|
||||
)
|
||||
schema_ref = content.get("schema", {}).get("$ref", "")
|
||||
component_name = schema_ref.split("/")[-1]
|
||||
# COMPONENT_SPLIT_REQUEST=True causes drf-spectacular to append "Request"
|
||||
# to request body component names, so StoragePathTestSerializer -> StoragePathTestRequest
|
||||
assert component_name == "StoragePathTestRequest", (
|
||||
f"Request body should reference StoragePathTestRequest, got {component_name!r}"
|
||||
)
|
||||
|
||||
|
||||
class TestProcessedMailBulkDeleteSchema:
|
||||
"""processed_mail_bulk_delete_create: response must be {result, deleted_mail_ids}."""
|
||||
|
||||
def _get_props(self, api_schema: SchemaGenerator):
|
||||
op = api_schema["paths"]["/api/processed_mail/bulk_delete/"]["post"]
|
||||
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
ref = resp_200.get("$ref", "")
|
||||
component_name = ref.split("/")[-1] if ref else ""
|
||||
if component_name:
|
||||
return api_schema["components"]["schemas"][component_name]["properties"]
|
||||
return resp_200.get("properties", {})
|
||||
|
||||
@pytest.mark.parametrize("field", ["result", "deleted_mail_ids"])
|
||||
def test_bulk_delete_response_has_field(
|
||||
self,
|
||||
api_schema: SchemaGenerator,
|
||||
field: str,
|
||||
):
|
||||
props = self._get_props(api_schema)
|
||||
assert field in props, f"bulk_delete 200 response must have a '{field}' field"
|
||||
|
||||
def test_bulk_delete_response_is_not_processed_mail_serializer(self, api_schema):
|
||||
op = api_schema["paths"]["/api/processed_mail/bulk_delete/"]["post"]
|
||||
resp_200 = op["responses"]["200"]["content"]["application/json"]["schema"]
|
||||
ref = resp_200.get("$ref", "")
|
||||
component_name = ref.split("/")[-1] if ref else ""
|
||||
assert component_name != "ProcessedMail", (
|
||||
"bulk_delete 200 response must not be the full ProcessedMail serializer"
|
||||
)
|
||||
|
||||
|
||||
class TestShareLinkBundleRebuildSchema:
|
||||
"""share_link_bundles_rebuild_create: 200 returns bundle data; 400 is documented."""
|
||||
|
||||
def test_rebuild_has_400_response(self, api_schema: SchemaGenerator):
|
||||
op = api_schema["paths"]["/api/share_link_bundles/{id}/rebuild/"]["post"]
|
||||
assert "400" in op["responses"], (
|
||||
"rebuild must document the 400 response for 'Bundle is already being processed.'"
|
||||
)
|
||||
|
||||
def test_rebuild_400_has_detail_field(self, api_schema: SchemaGenerator):
|
||||
op = api_schema["paths"]["/api/share_link_bundles/{id}/rebuild/"]["post"]
|
||||
resp_400 = op["responses"]["400"]["content"]["application/json"]["schema"]
|
||||
ref = resp_400.get("$ref", "")
|
||||
component_name = ref.split("/")[-1] if ref else ""
|
||||
if component_name:
|
||||
props = api_schema["components"]["schemas"][component_name]["properties"]
|
||||
else:
|
||||
props = resp_400.get("properties", {})
|
||||
assert "detail" in props, "rebuild 400 response must have a 'detail' field"
|
||||
|
||||
@@ -9,6 +9,7 @@ from collections import defaultdict
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from time import mktime
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -689,18 +690,49 @@ class EmailDocumentDetailSchema(EmailSerializer):
|
||||
"original_mime_type": serializers.CharField(),
|
||||
"media_filename": serializers.CharField(),
|
||||
"has_archive_version": serializers.BooleanField(),
|
||||
"original_metadata": serializers.DictField(),
|
||||
"archive_checksum": serializers.CharField(),
|
||||
"archive_media_filename": serializers.CharField(),
|
||||
"original_metadata": serializers.ListField(
|
||||
child=inline_serializer(
|
||||
name="OriginalMetadataEntry",
|
||||
fields={
|
||||
"namespace": serializers.CharField(),
|
||||
"prefix": serializers.CharField(),
|
||||
"key": serializers.CharField(),
|
||||
"value": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
),
|
||||
"archive_checksum": serializers.CharField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
),
|
||||
"archive_media_filename": serializers.CharField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
),
|
||||
"original_filename": serializers.CharField(),
|
||||
"archive_size": serializers.IntegerField(),
|
||||
"archive_metadata": serializers.DictField(),
|
||||
"archive_size": serializers.IntegerField(
|
||||
allow_null=True,
|
||||
required=False,
|
||||
),
|
||||
"archive_metadata": serializers.ListField(
|
||||
child=inline_serializer(
|
||||
name="ArchiveMetadataEntry",
|
||||
fields={
|
||||
"namespace": serializers.CharField(),
|
||||
"prefix": serializers.CharField(),
|
||||
"key": serializers.CharField(),
|
||||
"value": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
allow_null=True,
|
||||
required=False,
|
||||
),
|
||||
"lang": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
400: None,
|
||||
403: None,
|
||||
404: None,
|
||||
HTTPStatus.BAD_REQUEST: None,
|
||||
HTTPStatus.FORBIDDEN: None,
|
||||
HTTPStatus.NOT_FOUND: None,
|
||||
},
|
||||
),
|
||||
notes=extend_schema(
|
||||
@@ -3528,7 +3560,17 @@ class BulkDownloadView(DocumentSelectionMixin, GenericAPIView[Any]):
|
||||
return response
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer))
|
||||
@extend_schema_view(
|
||||
**generate_object_with_permissions_schema(StoragePathSerializer),
|
||||
test=extend_schema(
|
||||
operation_id="storage_paths_test",
|
||||
description="Test a storage path template against a document.",
|
||||
request=StoragePathTestSerializer,
|
||||
responses={
|
||||
(HTTPStatus.OK, "application/json"): OpenApiTypes.STR,
|
||||
},
|
||||
),
|
||||
)
|
||||
class StoragePathViewSet(PermissionsAwareDocumentCountMixin, ModelViewSet[StoragePath]):
|
||||
model = StoragePath
|
||||
|
||||
@@ -3985,6 +4027,19 @@ class ShareLinkViewSet(
|
||||
ordering_fields = ("created", "expiration", "document")
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
rebuild=extend_schema(
|
||||
operation_id="share_link_bundles_rebuild",
|
||||
description="Reset and re-queue a share link bundle for processing.",
|
||||
responses={
|
||||
HTTPStatus.OK: ShareLinkBundleSerializer,
|
||||
(HTTPStatus.BAD_REQUEST, "application/json"): inline_serializer(
|
||||
name="RebuildBundleError",
|
||||
fields={"detail": serializers.CharField()},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
||||
model = ShareLinkBundle
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import datetime
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
|
||||
from django.http import HttpResponseBadRequest
|
||||
@@ -160,6 +161,31 @@ class MailAccountViewSet(PassUserMixin, ModelViewSet[MailAccount]):
|
||||
return Response({"result": "OK"})
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
bulk_delete=extend_schema(
|
||||
operation_id="processed_mail_bulk_delete",
|
||||
description="Delete multiple processed mail records by ID.",
|
||||
request=inline_serializer(
|
||||
name="BulkDeleteMailRequest",
|
||||
fields={
|
||||
"mail_ids": serializers.ListField(child=serializers.IntegerField()),
|
||||
},
|
||||
),
|
||||
responses={
|
||||
(HTTPStatus.OK, "application/json"): inline_serializer(
|
||||
name="BulkDeleteMailResponse",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
"deleted_mail_ids": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
),
|
||||
},
|
||||
),
|
||||
HTTPStatus.BAD_REQUEST: None,
|
||||
HTTPStatus.FORBIDDEN: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
class ProcessedMailViewSet(PassUserMixin, ReadOnlyModelViewSet[ProcessedMail]):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
serializer_class = ProcessedMailSerializer
|
||||
|
||||
Reference in New Issue
Block a user