mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-09 10:41:24 +00:00
Compare commits
8 Commits
detangle-b
...
drop-api-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da7403543c | ||
|
|
4badf0e7c2 | ||
|
|
02f35089f4 | ||
|
|
bc26d94593 | ||
|
|
cf37a04fa5 | ||
|
|
7435a6b03f | ||
|
|
93cbbf34b7 | ||
|
|
1e8622494d |
29
docs/api.md
29
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: <server-version>
|
||||
```
|
||||
|
||||
2. Determine whether the client is compatible with this server based on
|
||||
|
||||
@@ -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):
|
||||
@@ -1440,6 +1325,124 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
||||
"set_permissions",
|
||||
]
|
||||
|
||||
def _get_api_version(self) -> int:
|
||||
request = self.context.get("request")
|
||||
return int(
|
||||
request.version if request else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
|
||||
)
|
||||
|
||||
def _update_legacy_visibility_preferences(
|
||||
self,
|
||||
saved_view_id: int,
|
||||
*,
|
||||
show_on_dashboard: bool | None,
|
||||
show_in_sidebar: bool | None,
|
||||
) -> UiSettings | None:
|
||||
if show_on_dashboard is None and show_in_sidebar is None:
|
||||
return None
|
||||
|
||||
request = self.context.get("request")
|
||||
user = request.user if request else self.user
|
||||
if user is None:
|
||||
return None
|
||||
|
||||
ui_settings, _ = UiSettings.objects.get_or_create(
|
||||
user=user,
|
||||
defaults={"settings": {}},
|
||||
)
|
||||
current_settings = (
|
||||
ui_settings.settings if isinstance(ui_settings.settings, dict) else {}
|
||||
)
|
||||
current_settings = dict(current_settings)
|
||||
|
||||
saved_views_settings = current_settings.get("saved_views")
|
||||
if isinstance(saved_views_settings, dict):
|
||||
saved_views_settings = dict(saved_views_settings)
|
||||
else:
|
||||
saved_views_settings = {}
|
||||
|
||||
dashboard_ids = {
|
||||
int(raw_id)
|
||||
for raw_id in saved_views_settings.get("dashboard_views_visible_ids", [])
|
||||
if str(raw_id).isdigit()
|
||||
}
|
||||
sidebar_ids = {
|
||||
int(raw_id)
|
||||
for raw_id in saved_views_settings.get("sidebar_views_visible_ids", [])
|
||||
if str(raw_id).isdigit()
|
||||
}
|
||||
|
||||
if show_on_dashboard is not None:
|
||||
if show_on_dashboard:
|
||||
dashboard_ids.add(saved_view_id)
|
||||
else:
|
||||
dashboard_ids.discard(saved_view_id)
|
||||
if show_in_sidebar is not None:
|
||||
if show_in_sidebar:
|
||||
sidebar_ids.add(saved_view_id)
|
||||
else:
|
||||
sidebar_ids.discard(saved_view_id)
|
||||
|
||||
saved_views_settings["dashboard_views_visible_ids"] = sorted(dashboard_ids)
|
||||
saved_views_settings["sidebar_views_visible_ids"] = sorted(sidebar_ids)
|
||||
current_settings["saved_views"] = saved_views_settings
|
||||
ui_settings.settings = current_settings
|
||||
ui_settings.save(update_fields=["settings"])
|
||||
return ui_settings
|
||||
|
||||
def to_representation(self, instance):
|
||||
# TODO: remove this and related backwards compatibility code when API v9 is dropped
|
||||
ret = super().to_representation(instance)
|
||||
request = self.context.get("request")
|
||||
api_version = self._get_api_version()
|
||||
|
||||
if api_version < 10:
|
||||
dashboard_ids = set()
|
||||
sidebar_ids = set()
|
||||
user = request.user if request else None
|
||||
if user is not None and hasattr(user, "ui_settings"):
|
||||
ui_settings = user.ui_settings.settings or None
|
||||
saved_views = None
|
||||
if isinstance(ui_settings, dict):
|
||||
saved_views = ui_settings.get("saved_views", {})
|
||||
if isinstance(saved_views, dict):
|
||||
dashboard_ids = set(
|
||||
saved_views.get("dashboard_views_visible_ids", []),
|
||||
)
|
||||
sidebar_ids = set(
|
||||
saved_views.get("sidebar_views_visible_ids", []),
|
||||
)
|
||||
ret["show_on_dashboard"] = instance.id in dashboard_ids
|
||||
ret["show_in_sidebar"] = instance.id in sidebar_ids
|
||||
|
||||
return ret
|
||||
|
||||
def to_internal_value(self, data):
|
||||
# TODO: remove this and related backwards compatibility code when API v9 is dropped
|
||||
api_version = self._get_api_version()
|
||||
if api_version >= 10:
|
||||
return super().to_internal_value(data)
|
||||
|
||||
normalized_data = data.copy()
|
||||
legacy_visibility_fields = {}
|
||||
boolean_field = serializers.BooleanField()
|
||||
|
||||
for field_name in ("show_on_dashboard", "show_in_sidebar"):
|
||||
if field_name in normalized_data:
|
||||
try:
|
||||
legacy_visibility_fields[field_name] = (
|
||||
boolean_field.to_internal_value(
|
||||
normalized_data.get(field_name),
|
||||
)
|
||||
)
|
||||
except serializers.ValidationError as exc:
|
||||
raise serializers.ValidationError({field_name: exc.detail})
|
||||
del normalized_data[field_name]
|
||||
|
||||
ret = super().to_internal_value(normalized_data)
|
||||
ret.update(legacy_visibility_fields)
|
||||
return ret
|
||||
|
||||
def validate(self, attrs):
|
||||
attrs = super().validate(attrs)
|
||||
if "display_fields" in attrs and attrs["display_fields"] is not None:
|
||||
@@ -1459,6 +1462,9 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
||||
return attrs
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
request = self.context.get("request")
|
||||
show_on_dashboard = validated_data.pop("show_on_dashboard", None)
|
||||
show_in_sidebar = validated_data.pop("show_in_sidebar", None)
|
||||
if "filter_rules" in validated_data:
|
||||
rules_data = validated_data.pop("filter_rules")
|
||||
else:
|
||||
@@ -1480,9 +1486,19 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
||||
SavedViewFilterRule.objects.filter(saved_view=instance).delete()
|
||||
for rule_data in rules_data:
|
||||
SavedViewFilterRule.objects.create(saved_view=instance, **rule_data)
|
||||
ui_settings = self._update_legacy_visibility_preferences(
|
||||
instance.id,
|
||||
show_on_dashboard=show_on_dashboard,
|
||||
show_in_sidebar=show_in_sidebar,
|
||||
)
|
||||
if request is not None and ui_settings is not None:
|
||||
request.user.ui_settings = ui_settings
|
||||
return instance
|
||||
|
||||
def create(self, validated_data):
|
||||
request = self.context.get("request")
|
||||
show_on_dashboard = validated_data.pop("show_on_dashboard", None)
|
||||
show_in_sidebar = validated_data.pop("show_in_sidebar", None)
|
||||
rules_data = validated_data.pop("filter_rules")
|
||||
if "user" in validated_data:
|
||||
# backwards compatibility
|
||||
@@ -1490,6 +1506,13 @@ class SavedViewSerializer(OwnedObjectSerializer):
|
||||
saved_view = super().create(validated_data)
|
||||
for rule_data in rules_data:
|
||||
SavedViewFilterRule.objects.create(saved_view=saved_view, **rule_data)
|
||||
ui_settings = self._update_legacy_visibility_preferences(
|
||||
saved_view.id,
|
||||
show_on_dashboard=show_on_dashboard,
|
||||
show_in_sidebar=show_in_sidebar,
|
||||
)
|
||||
if request is not None and ui_settings is not None:
|
||||
request.user.ui_settings = ui_settings
|
||||
return saved_view
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -41,6 +41,7 @@ from documents.models import SavedView
|
||||
from documents.models import ShareLink
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import UiSettings
|
||||
from documents.models import Workflow
|
||||
from documents.models import WorkflowAction
|
||||
from documents.models import WorkflowTrigger
|
||||
@@ -176,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",
|
||||
@@ -186,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)
|
||||
@@ -196,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",
|
||||
@@ -225,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")
|
||||
@@ -2200,6 +2189,205 @@ class TestDocumentApi(DirectoriesMixin, DocumentConsumeDelayMixin, APITestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data["count"], 0)
|
||||
|
||||
def test_saved_view_api_version_backward_compatibility(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Saved views and UiSettings with visibility preferences
|
||||
WHEN:
|
||||
- API request with version=9 (legacy)
|
||||
- API request with version=10 (current)
|
||||
THEN:
|
||||
- Version 9 returns show_on_dashboard and show_in_sidebar from UiSettings
|
||||
- Version 10 omits these fields (moved to UiSettings)
|
||||
"""
|
||||
v1 = SavedView.objects.create(
|
||||
owner=self.user,
|
||||
name="dashboard_view",
|
||||
sort_field="created",
|
||||
)
|
||||
v2 = SavedView.objects.create(
|
||||
owner=self.user,
|
||||
name="sidebar_view",
|
||||
sort_field="created",
|
||||
)
|
||||
v3 = SavedView.objects.create(
|
||||
owner=self.user,
|
||||
name="hidden_view",
|
||||
sort_field="created",
|
||||
)
|
||||
|
||||
UiSettings.objects.update_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
"settings": {
|
||||
"saved_views": {
|
||||
"dashboard_views_visible_ids": [v1.id],
|
||||
"sidebar_views_visible_ids": [v2.id],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
response_v9 = self.client.get(
|
||||
"/api/saved_views/",
|
||||
headers={"Accept": "application/json; version=9"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response_v9.status_code, status.HTTP_200_OK)
|
||||
results_v9 = {r["id"]: r for r in response_v9.data["results"]}
|
||||
self.assertIn("show_on_dashboard", results_v9[v1.id])
|
||||
self.assertIn("show_in_sidebar", results_v9[v1.id])
|
||||
self.assertTrue(results_v9[v1.id]["show_on_dashboard"])
|
||||
self.assertFalse(results_v9[v1.id]["show_in_sidebar"])
|
||||
self.assertTrue(results_v9[v2.id]["show_in_sidebar"])
|
||||
self.assertFalse(results_v9[v2.id]["show_on_dashboard"])
|
||||
self.assertFalse(results_v9[v3.id]["show_on_dashboard"])
|
||||
self.assertFalse(results_v9[v3.id]["show_in_sidebar"])
|
||||
|
||||
response_v10 = self.client.get(
|
||||
"/api/saved_views/",
|
||||
headers={"Accept": "application/json; version=10"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
|
||||
results_v10 = {r["id"]: r for r in response_v10.data["results"]}
|
||||
self.assertNotIn("show_on_dashboard", results_v10[v1.id])
|
||||
self.assertNotIn("show_in_sidebar", results_v10[v1.id])
|
||||
|
||||
def test_saved_view_api_version_9_user_without_ui_settings(self) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- User with no UiSettings and a saved view
|
||||
WHEN:
|
||||
- API request with version=9
|
||||
THEN:
|
||||
- show_on_dashboard and show_in_sidebar are False (default)
|
||||
"""
|
||||
SavedView.objects.create(
|
||||
owner=self.user,
|
||||
name="test_view",
|
||||
sort_field="created",
|
||||
)
|
||||
UiSettings.objects.filter(user=self.user).delete()
|
||||
|
||||
response = self.client.get(
|
||||
"/api/saved_views/",
|
||||
headers={"Accept": "application/json; version=9"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
result = response.data["results"][0]
|
||||
self.assertFalse(result["show_on_dashboard"])
|
||||
self.assertFalse(result["show_in_sidebar"])
|
||||
|
||||
def test_saved_view_api_version_9_create_writes_visibility_to_ui_settings(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- No UiSettings for the current user
|
||||
WHEN:
|
||||
- A saved view is created through API version 9 with visibility flags
|
||||
THEN:
|
||||
- Visibility is persisted in UiSettings.saved_views
|
||||
"""
|
||||
UiSettings.objects.filter(user=self.user).delete()
|
||||
|
||||
response = self.client.post(
|
||||
"/api/saved_views/",
|
||||
{
|
||||
"name": "legacy-v9-create",
|
||||
"sort_field": "created",
|
||||
"filter_rules": [],
|
||||
"show_on_dashboard": True,
|
||||
"show_in_sidebar": False,
|
||||
},
|
||||
headers={"Accept": "application/json; version=9"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
self.assertTrue(response.data["show_on_dashboard"])
|
||||
self.assertFalse(response.data["show_in_sidebar"])
|
||||
|
||||
self.user.refresh_from_db()
|
||||
self.assertTrue(hasattr(self.user, "ui_settings"))
|
||||
saved_view_settings = self.user.ui_settings.settings["saved_views"]
|
||||
self.assertListEqual(
|
||||
saved_view_settings["dashboard_views_visible_ids"],
|
||||
[response.data["id"]],
|
||||
)
|
||||
self.assertListEqual(saved_view_settings["sidebar_views_visible_ids"], [])
|
||||
|
||||
def test_saved_view_api_version_9_patch_writes_visibility_to_ui_settings(
|
||||
self,
|
||||
) -> None:
|
||||
"""
|
||||
GIVEN:
|
||||
- Existing saved views and UiSettings visibility ids
|
||||
WHEN:
|
||||
- A saved view is updated through API version 9 visibility flags
|
||||
THEN:
|
||||
- The per-user UiSettings visibility ids are updated
|
||||
"""
|
||||
v1 = SavedView.objects.create(
|
||||
owner=self.user,
|
||||
name="legacy-v9-patch-1",
|
||||
sort_field="created",
|
||||
)
|
||||
v2 = SavedView.objects.create(
|
||||
owner=self.user,
|
||||
name="legacy-v9-patch-2",
|
||||
sort_field="created",
|
||||
)
|
||||
UiSettings.objects.update_or_create(
|
||||
user=self.user,
|
||||
defaults={
|
||||
"settings": {
|
||||
"saved_views": {
|
||||
"dashboard_views_visible_ids": [v1.id],
|
||||
"sidebar_views_visible_ids": [v1.id, v2.id],
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/saved_views/{v1.id}/",
|
||||
{
|
||||
"show_on_dashboard": False,
|
||||
},
|
||||
headers={"Accept": "application/json; version=9"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertFalse(response.data["show_on_dashboard"])
|
||||
self.assertTrue(response.data["show_in_sidebar"])
|
||||
|
||||
self.user.refresh_from_db()
|
||||
saved_view_settings = self.user.ui_settings.settings["saved_views"]
|
||||
self.assertListEqual(saved_view_settings["dashboard_views_visible_ids"], [])
|
||||
self.assertListEqual(
|
||||
saved_view_settings["sidebar_views_visible_ids"],
|
||||
[v1.id, v2.id],
|
||||
)
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/saved_views/{v1.id}/",
|
||||
{
|
||||
"show_in_sidebar": False,
|
||||
},
|
||||
headers={"Accept": "application/json; version=9"},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertFalse(response.data["show_on_dashboard"])
|
||||
self.assertFalse(response.data["show_in_sidebar"])
|
||||
|
||||
self.user.refresh_from_db()
|
||||
saved_view_settings = self.user.ui_settings.settings["saved_views"]
|
||||
self.assertListEqual(saved_view_settings["dashboard_views_visible_ids"], [])
|
||||
self.assertListEqual(saved_view_settings["sidebar_views_visible_ids"], [v2.id])
|
||||
|
||||
def test_saved_view_create_update_patch(self) -> None:
|
||||
User.objects.create_user("user1")
|
||||
|
||||
@@ -2603,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:
|
||||
@@ -3391,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(
|
||||
|
||||
@@ -152,7 +152,7 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
|
||||
context={
|
||||
"request": types.SimpleNamespace(
|
||||
method="GET",
|
||||
version="7",
|
||||
version="9",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: paperless-ngx\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-03-06 20:00+0000\n"
|
||||
"POT-Creation-Date: 2026-03-09 01:51+0000\n"
|
||||
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: English\n"
|
||||
@@ -1299,7 +1299,7 @@ msgstr ""
|
||||
msgid "workflow runs"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:463 documents/serialisers.py:2344
|
||||
#: documents/serialisers.py:463 documents/serialisers.py:2482
|
||||
msgid "Insufficient permissions."
|
||||
msgstr ""
|
||||
|
||||
@@ -1307,39 +1307,39 @@ msgstr ""
|
||||
msgid "Invalid color."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:1967
|
||||
#: documents/serialisers.py:2105
|
||||
#, python-format
|
||||
msgid "File type %(type)s not supported"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2011
|
||||
#: documents/serialisers.py:2149
|
||||
#, python-format
|
||||
msgid "Custom field id must be an integer: %(id)s"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2018
|
||||
#: documents/serialisers.py:2156
|
||||
#, python-format
|
||||
msgid "Custom field with id %(id)s does not exist"
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2035 documents/serialisers.py:2045
|
||||
#: documents/serialisers.py:2173 documents/serialisers.py:2183
|
||||
msgid ""
|
||||
"Custom fields must be a list of integers or an object mapping ids to values."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2040
|
||||
#: documents/serialisers.py:2178
|
||||
msgid "Some custom fields don't exist or were specified twice."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2187
|
||||
#: documents/serialisers.py:2325
|
||||
msgid "Invalid variable detected."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2400
|
||||
#: documents/serialisers.py:2538
|
||||
msgid "Duplicate document identifiers are not allowed."
|
||||
msgstr ""
|
||||
|
||||
#: documents/serialisers.py:2430 documents/views.py:3328
|
||||
#: documents/serialisers.py:2568 documents/views.py:3328
|
||||
#, python-format
|
||||
msgid "Documents not found: %(ids)s"
|
||||
msgstr ""
|
||||
|
||||
@@ -379,7 +379,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",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user