From 3e32e903559a3f4540c8ce68b496d3b61da1feab Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:36:22 -0700 Subject: [PATCH] Breaking: drop support for api versions < 9 (#12284) --- docs/api.md | 29 ++--- src/documents/serialisers.py | 115 ------------------ src/documents/tests/test_api_custom_fields.py | 107 ---------------- src/documents/tests/test_api_documents.py | 43 +------ .../tests/test_api_filter_by_custom_fields.py | 2 +- src/paperless/settings/__init__.py | 2 +- 6 files changed, 20 insertions(+), 278 deletions(-) diff --git a/docs/api.md b/docs/api.md index 11ce3a8d5..9703e8eb7 100644 --- a/docs/api.md +++ b/docs/api.md @@ -369,41 +369,38 @@ operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json ## API Versioning -The REST API is versioned since Paperless-ngx 1.3.0. +The REST API is versioned. - Versioning ensures that changes to the API don't break older clients. - Clients specify the specific version of the API they wish to use with every request and Paperless will handle the request using the specified API version. -- Even if the underlying data model changes, older API versions will - always serve compatible data. -- If no version is specified, Paperless will serve version 1 to ensure - compatibility with older clients that do not request a specific API - version. +- Even if the underlying data model changes, supported older API + versions continue to serve compatible data. +- If no version is specified, Paperless serves the configured default + API version (currently `10`). +- Supported API versions are currently `9` and `10`. API versions are specified by submitting an additional HTTP `Accept` header with every request: ``` -Accept: application/json; version=6 +Accept: application/json; version=10 ``` -If an invalid version is specified, Paperless 1.3.0 will respond with -"406 Not Acceptable" and an error message in the body. Earlier -versions of Paperless will serve API version 1 regardless of whether a -version is specified via the `Accept` header. +If an invalid version is specified, Paperless responds with +`406 Not Acceptable` and an error message in the body. If a client wishes to verify whether it is compatible with any given server, the following procedure should be performed: -1. Perform an _authenticated_ request against any API endpoint. If the - server is on version 1.3.0 or newer, the server will add two custom - headers to the response: +1. Perform an _authenticated_ request against any API endpoint. The + server will add two custom headers to the response: ``` - X-Api-Version: 2 - X-Version: 1.3.0 + X-Api-Version: 10 + X-Version: ``` 2. Determine whether the client is compatible with this server based on diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index a04a8c655..1faa815e5 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -703,15 +703,6 @@ class StoragePathField(serializers.PrimaryKeyRelatedField): class CustomFieldSerializer(serializers.ModelSerializer): - def __init__(self, *args, **kwargs): - context = kwargs.get("context") - self.api_version = int( - context.get("request").version - if context and context.get("request") - else settings.REST_FRAMEWORK["DEFAULT_VERSION"], - ) - super().__init__(*args, **kwargs) - data_type = serializers.ChoiceField( choices=CustomField.FieldDataType, read_only=False, @@ -791,38 +782,6 @@ class CustomFieldSerializer(serializers.ModelSerializer): ) return super().validate(attrs) - def to_internal_value(self, data): - ret = super().to_internal_value(data) - - if ( - self.api_version < 7 - and ret.get("data_type", "") == CustomField.FieldDataType.SELECT - and isinstance(ret.get("extra_data", {}).get("select_options"), list) - ): - ret["extra_data"]["select_options"] = [ - { - "label": option, - "id": get_random_string(length=16), - } - for option in ret["extra_data"]["select_options"] - ] - - return ret - - def to_representation(self, instance): - ret = super().to_representation(instance) - - if ( - self.api_version < 7 - and instance.data_type == CustomField.FieldDataType.SELECT - ): - # Convert the select options with ids to a list of strings - ret["extra_data"]["select_options"] = [ - option["label"] for option in ret["extra_data"]["select_options"] - ] - - return ret - class ReadWriteSerializerMethodField(serializers.SerializerMethodField): """ @@ -937,50 +896,6 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer): return data - def get_api_version(self): - return int( - self.context.get("request").version - if self.context.get("request") - else settings.REST_FRAMEWORK["DEFAULT_VERSION"], - ) - - def to_internal_value(self, data): - ret = super().to_internal_value(data) - - if ( - self.get_api_version() < 7 - and ret.get("field").data_type == CustomField.FieldDataType.SELECT - and ret.get("value") is not None - ): - # Convert the index of the option in the field.extra_data["select_options"] - # list to the options unique id - ret["value"] = ret.get("field").extra_data["select_options"][ret["value"]][ - "id" - ] - - return ret - - def to_representation(self, instance): - ret = super().to_representation(instance) - - if ( - self.get_api_version() < 7 - and instance.field.data_type == CustomField.FieldDataType.SELECT - ): - # return the index of the option in the field.extra_data["select_options"] list - ret["value"] = next( - ( - idx - for idx, option in enumerate( - instance.field.extra_data["select_options"], - ) - if option["id"] == instance.value - ), - None, - ) - - return ret - class Meta: model = CustomFieldInstance fields = [ @@ -1004,20 +919,6 @@ class NotesSerializer(serializers.ModelSerializer): fields = ["id", "note", "created", "user"] ordering = ["-created"] - def to_representation(self, instance): - ret = super().to_representation(instance) - - request = self.context.get("request") - api_version = int( - request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"], - ) - - if api_version < 8 and "user" in ret: - user_id = ret["user"]["id"] - ret["user"] = user_id - - return ret - def _get_viewable_duplicates( document: Document, @@ -1172,22 +1073,6 @@ class DocumentSerializer( doc["content"] = getattr(instance, "effective_content") or "" if self.truncate_content and "content" in self.fields: doc["content"] = doc.get("content")[0:550] - - request = self.context.get("request") - api_version = int( - request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"], - ) - - if api_version < 9 and "created" in self.fields: - # provide created as a datetime for backwards compatibility - from django.utils import timezone - - doc["created"] = timezone.make_aware( - datetime.combine( - instance.created, - datetime.min.time(), - ), - ).isoformat() return doc def to_internal_value(self, data): diff --git a/src/documents/tests/test_api_custom_fields.py b/src/documents/tests/test_api_custom_fields.py index 9d9014b29..945f9e4d7 100644 --- a/src/documents/tests/test_api_custom_fields.py +++ b/src/documents/tests/test_api_custom_fields.py @@ -323,113 +323,6 @@ class TestCustomFieldsAPI(DirectoriesMixin, APITestCase): mock_delay.assert_called_once_with(cf_select) - def test_custom_field_select_old_version(self) -> None: - """ - GIVEN: - - Nothing - WHEN: - - API post request is made for custom fields with api version header < 7 - - API get request is made for custom fields with api version header < 7 - THEN: - - The select options are created with unique ids - - The select options are returned in the old format - """ - resp = self.client.post( - self.ENDPOINT, - headers={"Accept": "application/json; version=6"}, - data=json.dumps( - { - "data_type": "select", - "name": "Select Field", - "extra_data": { - "select_options": [ - "Option 1", - "Option 2", - ], - }, - }, - ), - content_type="application/json", - ) - self.assertEqual(resp.status_code, status.HTTP_201_CREATED) - - field = CustomField.objects.get(name="Select Field") - self.assertEqual( - field.extra_data["select_options"], - [ - {"label": "Option 1", "id": ANY}, - {"label": "Option 2", "id": ANY}, - ], - ) - - resp = self.client.get( - f"{self.ENDPOINT}{field.id}/", - headers={"Accept": "application/json; version=6"}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - data = resp.json() - self.assertEqual( - data["extra_data"]["select_options"], - [ - "Option 1", - "Option 2", - ], - ) - - def test_custom_field_select_value_old_version(self) -> None: - """ - GIVEN: - - Existing document with custom field select - WHEN: - - API post request is made to add the field for document with api version header < 7 - - API get request is made for document with api version header < 7 - THEN: - - The select value is returned in the old format, the index of the option - """ - custom_field_select = CustomField.objects.create( - name="Select Field", - data_type=CustomField.FieldDataType.SELECT, - extra_data={ - "select_options": [ - {"label": "Option 1", "id": "abc-123"}, - {"label": "Option 2", "id": "def-456"}, - ], - }, - ) - - doc = Document.objects.create( - title="WOW", - content="the content", - checksum="123", - mime_type="application/pdf", - ) - - resp = self.client.patch( - f"/api/documents/{doc.id}/", - headers={"Accept": "application/json; version=6"}, - data=json.dumps( - { - "custom_fields": [ - {"field": custom_field_select.id, "value": 1}, - ], - }, - ), - content_type="application/json", - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - doc.refresh_from_db() - self.assertEqual(doc.custom_fields.first().value, "def-456") - - resp = self.client.get( - f"/api/documents/{doc.id}/", - headers={"Accept": "application/json; version=6"}, - ) - self.assertEqual(resp.status_code, status.HTTP_200_OK) - - data = resp.json() - self.assertEqual(data["custom_fields"][0]["value"], 1) - def test_create_custom_field_monetary_validation(self) -> None: """ GIVEN: diff --git a/src/documents/tests/test_api_documents.py b/src/documents/tests/test_api_documents.py index 9fa2982c5..2dda91e98 100644 --- a/src/documents/tests/test_api_documents.py +++ b/src/documents/tests/test_api_documents.py @@ -177,7 +177,7 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): results = response.data["results"] self.assertEqual(len(results[0]), 0) - def test_document_fields_api_version_8_respects_created(self) -> None: + def test_document_fields_respects_created(self) -> None: Document.objects.create( title="legacy", checksum="123", @@ -187,7 +187,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): response = self.client.get( "/api/documents/?fields=id", - headers={"Accept": "application/json; version=8"}, format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -197,25 +196,22 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): response = self.client.get( "/api/documents/?fields=id,created", - headers={"Accept": "application/json; version=8"}, format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) results = response.data["results"] self.assertIn("id", results[0]) self.assertIn("created", results[0]) - self.assertRegex(results[0]["created"], r"^2024-01-15T00:00:00.*$") + self.assertEqual(results[0]["created"], "2024-01-15") - def test_document_legacy_created_format(self) -> None: + def test_document_created_format(self) -> None: """ GIVEN: - Existing document WHEN: - - Document is requested with api version ≥ 9 - - Document is requested with api version < 9 + - Document is requested THEN: - Document created field is returned as date - - Document created field is returned as datetime """ doc = Document.objects.create( title="none", @@ -226,14 +222,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): response = self.client.get( f"/api/documents/{doc.pk}/", - headers={"Accept": "application/json; version=8"}, - ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertRegex(response.data["created"], r"^2023-01-01T00:00:00.*$") - - response = self.client.get( - f"/api/documents/{doc.pk}/", - headers={"Accept": "application/json; version=9"}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data["created"], "2023-01-01") @@ -2803,26 +2791,6 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): }, ) - def test_docnote_serializer_v7(self) -> None: - doc = Document.objects.create( - title="test", - mime_type="application/pdf", - content="this is a document which will have notes!", - ) - Note.objects.create( - note="This is a note.", - document=doc, - user=self.user, - ) - self.assertEqual( - self.client.get( - f"/api/documents/{doc.pk}/", - headers={"Accept": "application/json; version=7"}, - format="json", - ).data["notes"][0]["user"], - self.user.id, - ) - def test_create_note(self) -> None: """ GIVEN: @@ -3591,14 +3559,13 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase): ) -class TestDocumentApiV2(DirectoriesMixin, APITestCase): +class TestDocumentApiTagColors(DirectoriesMixin, APITestCase): def setUp(self) -> None: super().setUp() self.user = User.objects.create_superuser(username="temp_admin") self.client.force_authenticate(user=self.user) - self.client.defaults["HTTP_ACCEPT"] = "application/json; version=2" def test_tag_validate_color(self) -> None: self.assertEqual( diff --git a/src/documents/tests/test_api_filter_by_custom_fields.py b/src/documents/tests/test_api_filter_by_custom_fields.py index 4fd755d58..b28fec777 100644 --- a/src/documents/tests/test_api_filter_by_custom_fields.py +++ b/src/documents/tests/test_api_filter_by_custom_fields.py @@ -152,7 +152,7 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase): context={ "request": types.SimpleNamespace( method="GET", - version="7", + version="9", ), }, ) diff --git a/src/paperless/settings/__init__.py b/src/paperless/settings/__init__.py index f247100e6..34d163dd7 100644 --- a/src/paperless/settings/__init__.py +++ b/src/paperless/settings/__init__.py @@ -155,7 +155,7 @@ REST_FRAMEWORK = { "DEFAULT_VERSION": "10", # match src-ui/src/environments/environment.prod.ts # Make sure these are ordered and that the most recent version appears # last. See api.md#api-versioning when adding new versions. - "ALLOWED_VERSIONS": ["2", "3", "4", "5", "6", "7", "8", "9", "10"], + "ALLOWED_VERSIONS": ["9", "10"], # DRF Spectacular default schema "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", }