]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: openapi spec, full api browser (#8948)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 10 Feb 2025 16:43:07 +0000 (08:43 -0800)
committerGitHub <noreply@github.com>
Mon, 10 Feb 2025 16:43:07 +0000 (16:43 +0000)
19 files changed:
Pipfile
Pipfile.lock
docs/api.md
src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts
src-ui/src/app/data/custom-field-query.ts
src/documents/apps.py
src/documents/filters.py
src/documents/schema.py [new file with mode: 0644]
src/documents/serialisers.py
src/documents/tests/test_api_permissions.py
src/documents/tests/test_api_schema.py [new file with mode: 0644]
src/documents/views.py
src/paperless/serialisers.py
src/paperless/settings.py
src/paperless/urls.py
src/paperless/views.py
src/paperless_mail/serialisers.py
src/paperless_mail/tests/test_api.py
src/paperless_mail/views.py

diff --git a/Pipfile b/Pipfile
index 1e69f316de34fcaec021e6b100732d085688b80d..863b157ec8bd961e40880970ba2966b8a4a682bf 100644 (file)
--- a/Pipfile
+++ b/Pipfile
@@ -20,6 +20,8 @@ django-multiselectfield = "*"
 django-soft-delete = "*"
 djangorestframework = "~=3.15.2"
 djangorestframework-guardian = "*"
+drf-spectacular = "*"
+drf-spectacular-sidecar = "*"
 drf-writable-nested = "*"
 bleach = "*"
 celery = {extras = ["redis"], version = "*"}
index 1f74e6708831e54afb74a26f409a038a218090f4..aed779ecb16ff40e6c5f5812c67b254dfe485a96 100644 (file)
@@ -1,7 +1,7 @@
 {
     "_meta": {
         "hash": {
-            "sha256": "6a7869231917d0cf6f5852520b5cb9b0df3802ed162b1a8107d0b1e1c37f0535"
+            "sha256": "9fdd406708b9c0693041c0506a29b7ab83ce196460ee299bfc764f1e03603e1a"
         },
         "pipfile-spec": 6,
         "requires": {},
             "markers": "python_version >= '3.8'",
             "version": "==5.0.1"
         },
+        "attrs": {
+            "hashes": [
+                "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346",
+                "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"
+            ],
+            "markers": "python_version >= '3.7'",
+            "version": "==24.2.0"
+        },
         "billiard": {
             "hashes": [
                 "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f",
             "index": "pypi",
             "version": "==0.3.0"
         },
+        "drf-spectacular": {
+            "hashes": [
+                "sha256:2c778a47a40ab2f5078a7c42e82baba07397bb35b074ae4680721b2805943061",
+                "sha256:856e7edf1056e49a4245e87a61e8da4baff46c83dbc25be1da2df77f354c7cb4"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.7'",
+            "version": "==0.28.0"
+        },
+        "drf-spectacular-sidecar": {
+            "hashes": [
+                "sha256:e2efd49c5bd1a607fd5d120d9da58d78e587852db8220b8880282a849296ff83",
+                "sha256:fcfccc72cbdbe41e93f8416fa0c712d14126b8d1629e65c09c07c8edea24aad0"
+            ],
+            "index": "pypi",
+            "markers": "python_version >= '3.6'",
+            "version": "==2024.11.1"
+        },
         "drf-writable-nested": {
             "hashes": [
                 "sha256:d8ddc606dc349e56373810842965712a5789e6a5ca7704729d15429b95f8f2ee"
             ],
             "version": "==0.5.1"
         },
+        "inflection": {
+            "hashes": [
+                "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417",
+                "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"
+            ],
+            "markers": "python_version >= '3.5'",
+            "version": "==0.5.1"
+        },
         "inotify-simple": {
             "hashes": [
                 "sha256:8440ffe49c4ae81a8df57c1ae1eb4b6bfa7acb830099bfb3e305b383005cc128"
             "markers": "python_version >= '3.8'",
             "version": "==1.4.2"
         },
+        "jsonschema": {
+            "hashes": [
+                "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4",
+                "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==4.23.0"
+        },
+        "jsonschema-specifications": {
+            "hashes": [
+                "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272",
+                "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf"
+            ],
+            "markers": "python_version >= '3.9'",
+            "version": "==2024.10.1"
+        },
         "kombu": {
             "hashes": [
                 "sha256:14212f5ccf022fc0a70453bb025a1dcc32782a588c49ea866884047d66e14763",
             "markers": "python_version >= '3.8'",
             "version": "==5.2.1"
         },
+        "referencing": {
+            "hashes": [
+                "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c",
+                "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"
+            ],
+            "markers": "python_version >= '3.8'",
+            "version": "==0.35.1"
+        },
         "regex": {
             "hashes": [
                 "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c",
             "markers": "python_full_version >= '3.8.0'",
             "version": "==13.9.4"
         },
+        "rpds-py": {
+            "hashes": [
+                "sha256:031819f906bb146561af051c7cef4ba2003d28cff07efacef59da973ff7969ba",
+                "sha256:0626238a43152918f9e72ede9a3b6ccc9e299adc8ade0d67c5e142d564c9a83d",
+                "sha256:085ed25baac88953d4283e5b5bd094b155075bb40d07c29c4f073e10623f9f2e",
+                "sha256:0a9e0759e7be10109645a9fddaaad0619d58c9bf30a3f248a2ea57a7c417173a",
+                "sha256:0c025820b78817db6a76413fff6866790786c38f95ea3f3d3c93dbb73b632202",
+                "sha256:1ff2eba7f6c0cb523d7e9cff0903f2fe1feff8f0b2ceb6bd71c0e20a4dcee271",
+                "sha256:20cc1ed0bcc86d8e1a7e968cce15be45178fd16e2ff656a243145e0b439bd250",
+                "sha256:241e6c125568493f553c3d0fdbb38c74babf54b45cef86439d4cd97ff8feb34d",
+                "sha256:2c51d99c30091f72a3c5d126fad26236c3f75716b8b5e5cf8effb18889ced928",
+                "sha256:2d6129137f43f7fa02d41542ffff4871d4aefa724a5fe38e2c31a4e0fd343fb0",
+                "sha256:30b912c965b2aa76ba5168fd610087bad7fcde47f0a8367ee8f1876086ee6d1d",
+                "sha256:30bdc973f10d28e0337f71d202ff29345320f8bc49a31c90e6c257e1ccef4333",
+                "sha256:320c808df533695326610a1b6a0a6e98f033e49de55d7dc36a13c8a30cfa756e",
+                "sha256:32eb88c30b6a4f0605508023b7141d043a79b14acb3b969aa0b4f99b25bc7d4a",
+                "sha256:3b766a9f57663396e4f34f5140b3595b233a7b146e94777b97a8413a1da1be18",
+                "sha256:3b929c2bb6e29ab31f12a1117c39f7e6d6450419ab7464a4ea9b0b417174f044",
+                "sha256:3e30a69a706e8ea20444b98a49f386c17b26f860aa9245329bab0851ed100677",
+                "sha256:3e53861b29a13d5b70116ea4230b5f0f3547b2c222c5daa090eb7c9c82d7f664",
+                "sha256:40c91c6e34cf016fa8e6b59d75e3dbe354830777fcfd74c58b279dceb7975b75",
+                "sha256:4991ca61656e3160cdaca4851151fd3f4a92e9eba5c7a530ab030d6aee96ec89",
+                "sha256:4ab2c2a26d2f69cdf833174f4d9d86118edc781ad9a8fa13970b527bf8236027",
+                "sha256:4e8921a259f54bfbc755c5bbd60c82bb2339ae0324163f32868f63f0ebb873d9",
+                "sha256:4eb2de8a147ffe0626bfdc275fc6563aa7bf4b6db59cf0d44f0ccd6ca625a24e",
+                "sha256:5145282a7cd2ac16ea0dc46b82167754d5e103a05614b724457cffe614f25bd8",
+                "sha256:520ed8b99b0bf86a176271f6fe23024323862ac674b1ce5b02a72bfeff3fff44",
+                "sha256:52c041802a6efa625ea18027a0723676a778869481d16803481ef6cc02ea8cb3",
+                "sha256:5555db3e618a77034954b9dc547eae94166391a98eb867905ec8fcbce1308d95",
+                "sha256:58a0e345be4b18e6b8501d3b0aa540dad90caeed814c515e5206bb2ec26736fd",
+                "sha256:590ef88db231c9c1eece44dcfefd7515d8bf0d986d64d0caf06a81998a9e8cab",
+                "sha256:5afb5efde74c54724e1a01118c6e5c15e54e642c42a1ba588ab1f03544ac8c7a",
+                "sha256:688c93b77e468d72579351a84b95f976bd7b3e84aa6686be6497045ba84be560",
+                "sha256:6b4ef7725386dc0762857097f6b7266a6cdd62bfd209664da6712cb26acef035",
+                "sha256:6bc0e697d4d79ab1aacbf20ee5f0df80359ecf55db33ff41481cf3e24f206919",
+                "sha256:6dcc4949be728ede49e6244eabd04064336012b37f5c2200e8ec8eb2988b209c",
+                "sha256:6f54e7106f0001244a5f4cf810ba8d3f9c542e2730821b16e969d6887b664266",
+                "sha256:808f1ac7cf3b44f81c9475475ceb221f982ef548e44e024ad5f9e7060649540e",
+                "sha256:8404b3717da03cbf773a1d275d01fec84ea007754ed380f63dfc24fb76ce4592",
+                "sha256:878f6fea96621fda5303a2867887686d7a198d9e0f8a40be100a63f5d60c88c9",
+                "sha256:8a7ff941004d74d55a47f916afc38494bd1cfd4b53c482b77c03147c91ac0ac3",
+                "sha256:95a5bad1ac8a5c77b4e658671642e4af3707f095d2b78a1fdd08af0dfb647624",
+                "sha256:97ef67d9bbc3e15584c2f3c74bcf064af36336c10d2e21a2131e123ce0f924c9",
+                "sha256:98486337f7b4f3c324ab402e83453e25bb844f44418c066623db88e4c56b7c7b",
+                "sha256:98e4fe5db40db87ce1c65031463a760ec7906ab230ad2249b4572c2fc3ef1f9f",
+                "sha256:998a8080c4495e4f72132f3d66ff91f5997d799e86cec6ee05342f8f3cda7dca",
+                "sha256:9afe42102b40007f588666bc7de82451e10c6788f6f70984629db193849dced1",
+                "sha256:9e20da3957bdf7824afdd4b6eeb29510e83e026473e04952dca565170cd1ecc8",
+                "sha256:a017f813f24b9df929674d0332a374d40d7f0162b326562daae8066b502d0590",
+                "sha256:a429b99337062877d7875e4ff1a51fe788424d522bd64a8c0a20ef3021fdb6ed",
+                "sha256:a58ce66847711c4aa2ecfcfaff04cb0327f907fead8945ffc47d9407f41ff952",
+                "sha256:a78d8b634c9df7f8d175451cfeac3810a702ccb85f98ec95797fa98b942cea11",
+                "sha256:a89a8ce9e4e75aeb7fa5d8ad0f3fecdee813802592f4f46a15754dcb2fd6b061",
+                "sha256:a8eeec67590e94189f434c6d11c426892e396ae59e4801d17a93ac96b8c02a6c",
+                "sha256:aaeb25ccfb9b9014a10eaf70904ebf3f79faaa8e60e99e19eef9f478651b9b74",
+                "sha256:ad116dda078d0bc4886cb7840e19811562acdc7a8e296ea6ec37e70326c1b41c",
+                "sha256:af04ac89c738e0f0f1b913918024c3eab6e3ace989518ea838807177d38a2e94",
+                "sha256:af4a644bf890f56e41e74be7d34e9511e4954894d544ec6b8efe1e21a1a8da6c",
+                "sha256:b21747f79f360e790525e6f6438c7569ddbfb1b3197b9e65043f25c3c9b489d8",
+                "sha256:b229ce052ddf1a01c67d68166c19cb004fb3612424921b81c46e7ea7ccf7c3bf",
+                "sha256:b4de1da871b5c0fd5537b26a6fc6814c3cc05cabe0c941db6e9044ffbb12f04a",
+                "sha256:b80b4690bbff51a034bfde9c9f6bf9357f0a8c61f548942b80f7b66356508bf5",
+                "sha256:b876f2bc27ab5954e2fd88890c071bd0ed18b9c50f6ec3de3c50a5ece612f7a6",
+                "sha256:b8f107395f2f1d151181880b69a2869c69e87ec079c49c0016ab96860b6acbe5",
+                "sha256:b9b76e2afd585803c53c5b29e992ecd183f68285b62fe2668383a18e74abe7a3",
+                "sha256:c2b2f71c6ad6c2e4fc9ed9401080badd1469fa9889657ec3abea42a3d6b2e1ed",
+                "sha256:c3761f62fcfccf0864cc4665b6e7c3f0c626f0380b41b8bd1ce322103fa3ef87",
+                "sha256:c38dbf31c57032667dd5a2f0568ccde66e868e8f78d5a0d27dcc56d70f3fcd3b",
+                "sha256:ca9989d5d9b1b300bc18e1801c67b9f6d2c66b8fd9621b36072ed1df2c977f72",
+                "sha256:cbd7504a10b0955ea287114f003b7ad62330c9e65ba012c6223dba646f6ffd05",
+                "sha256:d167e4dbbdac48bd58893c7e446684ad5d425b407f9336e04ab52e8b9194e2ed",
+                "sha256:d2132377f9deef0c4db89e65e8bb28644ff75a18df5293e132a8d67748397b9f",
+                "sha256:da52d62a96e61c1c444f3998c434e8b263c384f6d68aca8274d2e08d1906325c",
+                "sha256:daa8efac2a1273eed2354397a51216ae1e198ecbce9036fba4e7610b308b6153",
+                "sha256:dc5695c321e518d9f03b7ea6abb5ea3af4567766f9852ad1560f501b17588c7b",
+                "sha256:de552f4a1916e520f2703ec474d2b4d3f86d41f353e7680b597512ffe7eac5d0",
+                "sha256:de609a6f1b682f70bb7163da745ee815d8f230d97276db049ab447767466a09d",
+                "sha256:e12bb09678f38b7597b8346983d2323a6482dcd59e423d9448108c1be37cac9d",
+                "sha256:e168afe6bf6ab7ab46c8c375606298784ecbe3ba31c0980b7dcbb9631dcba97e",
+                "sha256:e78868e98f34f34a88e23ee9ccaeeec460e4eaf6db16d51d7a9b883e5e785a5e",
+                "sha256:e860f065cc4ea6f256d6f411aba4b1251255366e48e972f8a347cf88077b24fd",
+                "sha256:ea3a6ac4d74820c98fcc9da4a57847ad2cc36475a8bd9683f32ab6d47a2bd682",
+                "sha256:ebf64e281a06c904a7636781d2e973d1f0926a5b8b480ac658dc0f556e7779f4",
+                "sha256:ed6378c9d66d0de903763e7706383d60c33829581f0adff47b6535f1802fa6db",
+                "sha256:ee1e4fc267b437bb89990b2f2abf6c25765b89b72dd4a11e21934df449e0c976",
+                "sha256:ee4eafd77cc98d355a0d02f263efc0d3ae3ce4a7c24740010a8b4012bbb24937",
+                "sha256:efec946f331349dfc4ae9d0e034c263ddde19414fe5128580f512619abed05f1",
+                "sha256:f414da5c51bf350e4b7960644617c130140423882305f7574b6cf65a3081cecb",
+                "sha256:f71009b0d5e94c0e86533c0b27ed7cacc1239cb51c178fd239c3cfefefb0400a",
+                "sha256:f983e4c2f603c95dde63df633eec42955508eefd8d0f0e6d236d31a044c882d7",
+                "sha256:faa5e8496c530f9c71f2b4e1c49758b06e5f4055e17144906245c99fa6d45356",
+                "sha256:fed5dfefdf384d6fe975cc026886aece4f292feaf69d0eeb716cfd3c5a4dd8be"
+            ],
+            "markers": "python_version >= '3.9'",
+            "version": "==0.21.0"
+        },
         "scikit-learn": {
             "hashes": [
                 "sha256:0650e730afb87402baa88afbf31c07b84c98272622aaba002559b614600ca691",
             "markers": "python_version >= '3.8'",
             "version": "==5.2"
         },
+        "uritemplate": {
+            "hashes": [
+                "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0",
+                "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"
+            ],
+            "markers": "python_version >= '3.6'",
+            "version": "==4.1.1"
+        },
         "urllib3": {
             "hashes": [
                 "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df",
index 050443c1908e13a69205b3eaf0f46ac8920a2f57..9c28476c40ce044b95981c00d6c1a7cb8d8aed1d 100644 (file)
 # The REST API
 
-Paperless makes use of the [Django REST
-Framework](https://django-rest-framework.org/) standard API interface. It
-provides a browsable API for most of its endpoints, which you can
-inspect at `http://<paperless-host>:<port>/api/`. This also documents
-most of the available filters and ordering fields.
-
-The API provides the following main endpoints:
-
--   `/api/correspondents/`: Full CRUD support.
--   `/api/custom_fields/`: Full CRUD support.
--   `/api/documents/`: Full CRUD support, except POSTing new documents.
-    See [below](#file-uploads).
--   `/api/document_types/`: Full CRUD support.
--   `/api/groups/`: Full CRUD support.
--   `/api/logs/`: Read-Only.
--   `/api/mail_accounts/`: Full CRUD support.
--   `/api/mail_rules/`: Full CRUD support.
--   `/api/profile/`: GET, PATCH
--   `/api/share_links/`: Full CRUD support.
--   `/api/storage_paths/`: Full CRUD support.
--   `/api/tags/`: Full CRUD support.
--   `/api/tasks/`: Read-only.
--   `/api/users/`: Full CRUD support.
--   `/api/workflows/`: Full CRUD support.
--   `/api/search/` GET, see [below](#global-search).
-
-All of these endpoints except for the logging endpoint allow you to
-fetch (and edit and delete where appropriate) individual objects by
-appending their primary key to the path, e.g. `/api/documents/454/`.
-
-The objects served by the document endpoint contain the following
-fields:
-
--   `id`: ID of the document. Read-only.
--   `title`: Title of the document.
--   `content`: Plain text content of the document.
--   `tags`: List of IDs of tags assigned to this document, or empty
-    list.
--   `document_type`: Document type of this document, or null.
--   `correspondent`: Correspondent of this document or null.
--   `created`: The date time at which this document was created.
--   `created_date`: The date (YYYY-MM-DD) at which this document was
-    created. Optional. If also passed with created, this is ignored.
--   `modified`: The date at which this document was last edited in
-    paperless. Read-only.
--   `added`: The date at which this document was added to paperless.
-    Read-only.
--   `archive_serial_number`: The identifier of this document in a
-    physical document archive.
--   `original_file_name`: Verbose filename of the original document.
-    Read-only.
--   `archived_file_name`: Verbose filename of the archived document.
-    Read-only. Null if no archived document is available.
--   `notes`: Array of notes associated with the document.
--   `page_count`: Number of pages.
--   `set_permissions`: Allows setting document permissions. Optional,
-    write-only. See [below](#permissions).
--   `custom_fields`: Array of custom fields & values, specified as
-    `{ field: CUSTOM_FIELD_ID, value: VALUE }`
+Paperless-ngx now ships with a fully-documented REST API and a browsable
+web interface to explore it. The API browsable interface is available at
+`/api/schema/view/`.
 
-!!! note
-
-    Note that all endpoint URLs must end with a `/`slash.
-
-## Downloading documents
-
-In addition to that, the document endpoint offers these additional
-actions on individual documents:
-
--   `/api/documents/<pk>/download/`: Download the document.
--   `/api/documents/<pk>/preview/`: Display the document inline, without
-    downloading it.
--   `/api/documents/<pk>/thumb/`: Download the PNG thumbnail of a
-    document.
-
-Paperless generates archived PDF/A documents from consumed files and
-stores both the original files as well as the archived files. By
-default, the endpoints for previews and downloads serve the archived
-file, if it is available. Otherwise, the original file is served. Some
-document cannot be archived.
-
-The endpoints correctly serve the response header fields
-`Content-Disposition` and `Content-Type` to indicate the filename for
-download and the type of content of the document.
-
-In order to download or preview the original document when an archived
-document is available, supply the query parameter `original=true`.
-
-!!! tip
-
-    Paperless used to provide these functionality at `/fetch/<pk>/preview`,
-    `/fetch/<pk>/thumb` and `/fetch/<pk>/doc`. Redirects to the new URLs are
-    in place. However, if you use these old URLs to access documents, you
-    should update your app or script to use the new URLs.
-
-## Getting document metadata
-
-The api also has an endpoint to retrieve read-only metadata about
-specific documents. this information is not served along with the
-document objects, since it requires reading files and would therefore
-slow down document lists considerably.
-
-Access the metadata of a document with an ID `id` at
-`/api/documents/<id>/metadata/`.
-
-The endpoint reports the following data:
-
--   `original_checksum`: MD5 checksum of the original document.
--   `original_size`: Size of the original document, in bytes.
--   `original_mime_type`: Mime type of the original document.
--   `media_filename`: Current filename of the document, under which it
-    is stored inside the media directory.
--   `has_archive_version`: True, if this document is archived, false
-    otherwise.
--   `original_metadata`: A list of metadata associated with the original
-    document. See below.
--   `archive_checksum`: MD5 checksum of the archived document, or null.
--   `archive_size`: Size of the archived document in bytes, or null.
--   `archive_metadata`: Metadata associated with the archived document,
-    or null. See below.
-
-File metadata is reported as a list of objects in the following form:
-
-```json
-[
-    {
-        "namespace": "http://ns.adobe.com/pdf/1.3/",
-        "prefix": "pdf",
-        "key": "Producer",
-        "value": "SparklePDF, Fancy edition"
-    }
-]
-```
-
-`namespace` and `prefix` can be null. The actual metadata reported
-depends on the file type and the metadata available in that specific
-document. Paperless only reports PDF metadata at this point.
-
-## Documents additional endpoints
-
--   `/api/documents/<id>/notes/`: Retrieve notes for a document.
--   `/api/documents/<id>/share_links/`: Retrieve share links for a document.
--   `/api/documents/<id>/history/`: Retrieve history of changes for a document.
+Further documentation is provided here for some endpoints and features.
 
 ## Authorization
 
@@ -190,38 +52,6 @@ The REST api provides four different forms of authentication.
     [configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
     you can authenticate against the API using Remote User auth.
 
-## Global search
-
-A global search endpoint is available at `/api/search/` and requires a search term
-of > 2 characters e.g. `?query=foo`. This endpoint returns a maximum of 3 results
-across nearly all objects, e.g. documents, tags, saved views, mail rules, etc.
-Results are only included if the requesting user has the appropriate permissions.
-
-Results are returned in the following format:
-
-```json
-{
-  total: number
-  documents: []
-  saved_views: []
-  correspondents: []
-  document_types: []
-  storage_paths: []
-  tags: []
-  users: []
-  groups: []
-  mail_accounts: []
-  mail_rules: []
-  custom_fields: []
-  workflows: []
-}
-```
-
-Global search first searches objects by name (or title for documents) matching the query.
-If the optional `db_only` parameter is set, only document titles will be searched. Otherwise,
-if the amount of documents returned by a simple title string search is < 3, results from the
-search index will also be included.
-
 ## Searching for documents
 
 Full text searching is available on the `/api/documents/` endpoint. Two
@@ -365,10 +195,6 @@ The endpoint supports the following optional form fields:
 -   `custom_fields`: An array of custom field ids to assign (with an empty
     value) to the document.
 
-!!! note
-
-    Sending a `Content-Length` header with correct size is mandatory.
-
 The endpoint will immediately return HTTP 200 if the document consumption
 process was started successfully, with the UUID of the consumption task
 as the data. No additional status information about the consumption process
index 7afb5fc1c5b56d9cc27307f18e1ac167a8ba4536..4dcbceb13aa5ea2595aed4402bbabba873fcf91b 100644 (file)
@@ -113,6 +113,9 @@ describe('CustomFieldsQueryDropdownComponent', () => {
         ...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
           CustomFieldQueryOperatorGroups.Basic
         ],
+        ...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
+          CustomFieldQueryOperatorGroups.Exact
+        ],
         ...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
           CustomFieldQueryOperatorGroups.String
         ],
index 226a1060502fe6fc39c08f6fa17a1b33180d5dc2..084b7a330a621094b0d1105f180396b6be3f20e7 100644 (file)
@@ -36,6 +36,7 @@ export const CUSTOM_FIELD_QUERY_OPERATOR_LABELS = {
 
 export enum CustomFieldQueryOperatorGroups {
   Basic = 'basic',
+  Exact = 'exact',
   String = 'string',
   Arithmetic = 'arithmetic',
   Containment = 'containment',
@@ -48,8 +49,8 @@ export const CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP = {
   [CustomFieldQueryOperatorGroups.Basic]: [
     CustomFieldQueryOperator.Exists,
     CustomFieldQueryOperator.IsNull,
-    CustomFieldQueryOperator.Exact,
   ],
+  [CustomFieldQueryOperatorGroups.Exact]: [CustomFieldQueryOperator.Exact],
   [CustomFieldQueryOperatorGroups.String]: [CustomFieldQueryOperator.IContains],
   [CustomFieldQueryOperatorGroups.Arithmetic]: [
     CustomFieldQueryOperator.GreaterThan,
@@ -71,27 +72,33 @@ export const CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP = {
 export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
   [CustomFieldDataType.String]: [
     CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Exact,
     CustomFieldQueryOperatorGroups.String,
   ],
   [CustomFieldDataType.Url]: [
     CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Exact,
     CustomFieldQueryOperatorGroups.String,
   ],
   [CustomFieldDataType.Date]: [
     CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Exact,
     CustomFieldQueryOperatorGroups.Date,
   ],
   [CustomFieldDataType.Boolean]: [CustomFieldQueryOperatorGroups.Basic],
   [CustomFieldDataType.Integer]: [
     CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Exact,
     CustomFieldQueryOperatorGroups.Arithmetic,
   ],
   [CustomFieldDataType.Float]: [
     CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Exact,
     CustomFieldQueryOperatorGroups.Arithmetic,
   ],
   [CustomFieldDataType.Monetary]: [
     CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Exact,
     CustomFieldQueryOperatorGroups.String,
     CustomFieldQueryOperatorGroups.Arithmetic,
   ],
@@ -101,6 +108,7 @@ export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
   ],
   [CustomFieldDataType.Select]: [
     CustomFieldQueryOperatorGroups.Basic,
+    CustomFieldQueryOperatorGroups.Exact,
     CustomFieldQueryOperatorGroups.Subset,
   ],
 }
index ac1bb21ebb59c123290a05758eb5750b999a8de3..f3b798c0b5bd21689010418c229f4a19a545b037 100644 (file)
@@ -28,4 +28,6 @@ class DocumentsConfig(AppConfig):
         document_consumption_finished.connect(run_workflows_added)
         document_updated.connect(run_workflows_updated)
 
+        import documents.schema  # noqa: F401
+
         AppConfig.ready(self)
index 21a9422add06ce803b7af400f3d8f100d6583d10..1ce782ee6806c766219d4cb2c3df8f4b4d918087 100644 (file)
@@ -22,6 +22,7 @@ from django.utils.translation import gettext_lazy as _
 from django_filters.rest_framework import BooleanFilter
 from django_filters.rest_framework import Filter
 from django_filters.rest_framework import FilterSet
+from drf_spectacular.utils import extend_schema_field
 from guardian.utils import get_group_obj_perms_model
 from guardian.utils import get_user_obj_perms_model
 from rest_framework import serializers
@@ -124,6 +125,7 @@ class ObjectFilter(Filter):
         return qs
 
 
+@extend_schema_field(serializers.BooleanField)
 class InboxFilter(Filter):
     def filter(self, qs, value):
         if value == "true":
@@ -134,6 +136,7 @@ class InboxFilter(Filter):
             return qs
 
 
+@extend_schema_field(serializers.CharField)
 class TitleContentFilter(Filter):
     def filter(self, qs, value):
         if value:
@@ -142,6 +145,7 @@ class TitleContentFilter(Filter):
             return qs
 
 
+@extend_schema_field(serializers.BooleanField)
 class SharedByUser(Filter):
     def filter(self, qs, value):
         ctype = ContentType.objects.get_for_model(self.model)
@@ -186,6 +190,7 @@ class CustomFieldFilterSet(FilterSet):
         }
 
 
+@extend_schema_field(serializers.CharField)
 class CustomFieldsFilter(Filter):
     def filter(self, qs, value):
         if value:
@@ -642,6 +647,7 @@ class CustomFieldQueryParser:
             self._current_depth -= 1
 
 
+@extend_schema_field(serializers.CharField)
 class CustomFieldQueryFilter(Filter):
     def __init__(self, validation_prefix):
         """
diff --git a/src/documents/schema.py b/src/documents/schema.py
new file mode 100644 (file)
index 0000000..2ab421a
--- /dev/null
@@ -0,0 +1,44 @@
+from drf_spectacular.extensions import OpenApiAuthenticationExtension
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter
+from drf_spectacular.utils import extend_schema
+
+
+class AngularApiAuthenticationOverrideScheme(OpenApiAuthenticationExtension):
+    target_class = "paperless.auth.AngularApiAuthenticationOverride"
+    name = "AngularApiAuthenticationOverride"
+
+    def get_security_definition(self, auto_schema):  # pragma: no cover
+        return {
+            "type": "http",
+            "scheme": "basic",
+        }
+
+
+class PaperelessBasicAuthenticationScheme(OpenApiAuthenticationExtension):
+    target_class = "paperless.auth.PaperlessBasicAuthentication"
+    name = "PaperelessBasicAuthentication"
+
+    def get_security_definition(self, auto_schema):
+        return {
+            "type": "http",
+            "scheme": "basic",
+        }
+
+
+def generate_object_with_permissions_schema(serializer_class):
+    return {
+        operation: extend_schema(
+            parameters=[
+                OpenApiParameter(
+                    name="full_perms",
+                    type=OpenApiTypes.BOOL,
+                    location=OpenApiParameter.QUERY,
+                ),
+            ],
+            responses={
+                200: serializer_class(many=operation == "list", all_fields=True),
+            },
+        )
+        for operation in ["list", "retrieve"]
+    }
index 4adadbcb2655e384972ffa02718360a48a3971f1..6a0a1eec1ad720af4bd18d93ae1b41747089de9e 100644 (file)
@@ -19,6 +19,7 @@ from django.core.validators import integer_validator
 from django.utils.crypto import get_random_string
 from django.utils.text import slugify
 from django.utils.translation import gettext as _
+from drf_spectacular.utils import extend_schema_field
 from drf_writable_nested.serializers import NestedUpdateMixin
 from guardian.core import ObjectPermissionChecker
 from guardian.shortcuts import get_users_with_perms
@@ -86,7 +87,7 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
 class MatchingModelSerializer(serializers.ModelSerializer):
     document_count = serializers.IntegerField(read_only=True)
 
-    def get_slug(self, obj):
+    def get_slug(self, obj) -> str:
         return slugify(obj.name)
 
     slug = SerializerMethodField()
@@ -179,9 +180,47 @@ class SerializerWithPerms(serializers.Serializer):
     def __init__(self, *args, **kwargs):
         self.user = kwargs.pop("user", None)
         self.full_perms = kwargs.pop("full_perms", False)
+        self.all_fields = kwargs.pop("all_fields", False)
         super().__init__(*args, **kwargs)
 
 
+@extend_schema_field(
+    field={
+        "type": "object",
+        "properties": {
+            "view": {
+                "type": "object",
+                "properties": {
+                    "users": {
+                        "type": "array",
+                        "items": {"type": "integer"},
+                    },
+                    "groups": {
+                        "type": "array",
+                        "items": {"type": "integer"},
+                    },
+                },
+            },
+            "change": {
+                "type": "object",
+                "properties": {
+                    "users": {
+                        "type": "array",
+                        "items": {"type": "integer"},
+                    },
+                    "groups": {
+                        "type": "array",
+                        "items": {"type": "integer"},
+                    },
+                },
+            },
+        },
+    },
+)
+class SetPermissionsSerializer(serializers.DictField):
+    pass
+
+
 class OwnedObjectSerializer(
     SerializerWithPerms,
     serializers.ModelSerializer,
@@ -190,16 +229,50 @@ class OwnedObjectSerializer(
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        try:
-            if self.full_perms:
-                self.fields.pop("user_can_change")
-                self.fields.pop("is_shared_by_requester")
-            else:
-                self.fields.pop("permissions")
-        except KeyError:
-            pass
+        if not self.all_fields:
+            try:
+                if self.full_perms:
+                    self.fields.pop("user_can_change")
+                    self.fields.pop("is_shared_by_requester")
+                else:
+                    self.fields.pop("permissions")
+            except KeyError:
+                pass
 
-    def get_permissions(self, obj):
+    @extend_schema_field(
+        field={
+            "type": "object",
+            "properties": {
+                "view": {
+                    "type": "object",
+                    "properties": {
+                        "users": {
+                            "type": "array",
+                            "items": {"type": "integer"},
+                        },
+                        "groups": {
+                            "type": "array",
+                            "items": {"type": "integer"},
+                        },
+                    },
+                },
+                "change": {
+                    "type": "object",
+                    "properties": {
+                        "users": {
+                            "type": "array",
+                            "items": {"type": "integer"},
+                        },
+                        "groups": {
+                            "type": "array",
+                            "items": {"type": "integer"},
+                        },
+                    },
+                },
+            },
+        },
+    )
+    def get_permissions(self, obj) -> dict:
         view_codename = f"view_{obj.__class__.__name__.lower()}"
         change_codename = f"change_{obj.__class__.__name__.lower()}"
 
@@ -228,7 +301,7 @@ class OwnedObjectSerializer(
             },
         }
 
-    def get_user_can_change(self, obj):
+    def get_user_can_change(self, obj) -> bool:
         checker = ObjectPermissionChecker(self.user) if self.user is not None else None
         return (
             obj.owner is None
@@ -271,7 +344,7 @@ class OwnedObjectSerializer(
 
         return set(user_permission_pks) | set(group_permission_pks)
 
-    def get_is_shared_by_requester(self, obj: Document):
+    def get_is_shared_by_requester(self, obj: Document) -> bool:
         # First check the context to see if `shared_object_pks` is set by the parent.
         shared_object_pks = self.context.get("shared_object_pks")
         # If not just check if the current object is shared.
@@ -283,7 +356,7 @@ class OwnedObjectSerializer(
     user_can_change = SerializerMethodField(read_only=True)
     is_shared_by_requester = SerializerMethodField(read_only=True)
 
-    set_permissions = serializers.DictField(
+    set_permissions = SetPermissionsSerializer(
         label="Set permissions",
         allow_empty=True,
         required=False,
@@ -380,7 +453,7 @@ class DocumentTypeSerializer(MatchingModelSerializer, OwnedObjectSerializer):
         )
 
 
-class ColorField(serializers.Field):
+class DeprecatedColors:
     COLOURS = (
         (1, "#a6cee3"),
         (2, "#1f78b4"),
@@ -397,14 +470,21 @@ class ColorField(serializers.Field):
         (13, "#cccccc"),
     )
 
+
+@extend_schema_field(
+    serializers.ChoiceField(
+        choices=DeprecatedColors.COLOURS,
+    ),
+)
+class ColorField(serializers.Field):
     def to_internal_value(self, data):
-        for id, color in self.COLOURS:
+        for id, color in DeprecatedColors.COLOURS:
             if id == data:
                 return color
         raise serializers.ValidationError
 
     def to_representation(self, value):
-        for id, color in self.COLOURS:
+        for id, color in DeprecatedColors.COLOURS:
             if color == value:
                 return id
         return 1
@@ -433,7 +513,7 @@ class TagSerializerVersion1(MatchingModelSerializer, OwnedObjectSerializer):
 
 
 class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
-    def get_text_color(self, obj):
+    def get_text_color(self, obj) -> str:
         try:
             h = obj.color.lstrip("#")
             rgb = tuple(int(h[i : i + 2], 16) / 256 for i in (0, 2, 4))
@@ -499,7 +579,7 @@ class CustomFieldSerializer(serializers.ModelSerializer):
         context = kwargs.get("context")
         self.api_version = int(
             context.get("request").version
-            if context.get("request")
+            if context and context.get("request")
             else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
         )
         super().__init__(*args, **kwargs)
@@ -657,7 +737,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
         )
         return instance
 
-    def get_value(self, obj: CustomFieldInstance):
+    def get_value(self, obj: CustomFieldInstance) -> str | int | float | dict | None:
         return obj.value
 
     def validate(self, data):
@@ -808,13 +888,13 @@ class DocumentSerializer(
         required=False,
     )
 
-    def get_page_count(self, obj):
+    def get_page_count(self, obj) -> int | None:
         return obj.page_count
 
-    def get_original_file_name(self, obj):
+    def get_original_file_name(self, obj) -> str | None:
         return obj.original_filename
 
-    def get_archived_file_name(self, obj):
+    def get_archived_file_name(self, obj) -> str | None:
         if obj.has_archive_version:
             return obj.get_public_filename(archive=True)
         else:
@@ -911,7 +991,7 @@ class DocumentSerializer(
 
         # return full permissions if we're doing a PATCH or PUT
         context = kwargs.get("context")
-        if (
+        if context is not None and (
             context.get("request").method == "PATCH"
             or context.get("request").method == "PUT"
         ):
@@ -921,7 +1001,6 @@ class DocumentSerializer(
 
     class Meta:
         model = Document
-        depth = 1
         fields = (
             "id",
             "correspondent",
@@ -1606,7 +1685,6 @@ class UiSettingsViewSerializer(serializers.ModelSerializer):
 class TasksViewSerializer(OwnedObjectSerializer):
     class Meta:
         model = PaperlessTask
-        depth = 1
         fields = (
             "id",
             "task_id",
@@ -1623,7 +1701,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
 
     type = serializers.SerializerMethodField()
 
-    def get_type(self, obj):
+    def get_type(self, obj) -> str:
         # just file tasks, for now
         return "file"
 
@@ -1631,7 +1709,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
     created_doc_re = re.compile(r"New document id (\d+) created")
     duplicate_doc_re = re.compile(r"It is a duplicate of .* \(#(\d+)\)")
 
-    def get_related_document(self, obj):
+    def get_related_document(self, obj) -> str | None:
         result = None
         re = None
         match obj.status:
index 3785c8f2a6cc9369142dfa133f3e9ee72c30ffa1..637bd1fe049b2593bcc79e31deaf647340d602f3 100644 (file)
@@ -88,14 +88,14 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
         )
 
     def test_api_version_no_auth(self):
-        response = self.client.get("/api/")
+        response = self.client.get("/api/documents/")
         self.assertNotIn("X-Api-Version", response)
         self.assertNotIn("X-Version", response)
 
     def test_api_version_with_auth(self):
         user = User.objects.create_superuser(username="test")
         self.client.force_authenticate(user)
-        response = self.client.get("/api/")
+        response = self.client.get("/api/documents/")
         self.assertIn("X-Api-Version", response)
         self.assertIn("X-Version", response)
 
diff --git a/src/documents/tests/test_api_schema.py b/src/documents/tests/test_api_schema.py
new file mode 100644 (file)
index 0000000..fc2e0fd
--- /dev/null
@@ -0,0 +1,27 @@
+from django.core.management import call_command
+from django.core.management.base import CommandError
+from rest_framework import status
+from rest_framework.test import APITestCase
+
+
+class TestApiSchema(APITestCase):
+    ENDPOINT = "/api/schema/"
+
+    def test_valid_schema(self):
+        """
+        Test that the schema is valid
+        """
+        try:
+            call_command("spectacular", "--validate", "--fail-on-warn")
+        except CommandError as e:
+            self.fail(f"Schema validation failed: {e}")
+
+    def test_get_schema_endpoints(self):
+        """
+        Test that the schema endpoints exist and return a 200 status code
+        """
+        schema_response = self.client.get(self.ENDPOINT)
+        self.assertEqual(schema_response.status_code, status.HTTP_200_OK)
+
+        ui_response = self.client.get(self.ENDPOINT + "view/")
+        self.assertEqual(ui_response.status_code, status.HTTP_200_OK)
index f23c1b95308a8cf0d6c2aa68232c3a33c14c7392..a856883f3c22eb69835df6b84c87259069ae1308 100644 (file)
@@ -48,10 +48,16 @@ from django.views.decorators.http import condition
 from django.views.decorators.http import last_modified
 from django.views.generic import TemplateView
 from django_filters.rest_framework import DjangoFilterBackend
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import OpenApiParameter
+from drf_spectacular.utils import extend_schema
+from drf_spectacular.utils import extend_schema_view
+from drf_spectacular.utils import inline_serializer
 from langdetect import detect
 from packaging import version as packaging_version
 from redis import Redis
 from rest_framework import parsers
+from rest_framework import serializers
 from rest_framework.decorators import action
 from rest_framework.exceptions import NotFound
 from rest_framework.filters import OrderingFilter
@@ -63,7 +69,6 @@ from rest_framework.mixins import RetrieveModelMixin
 from rest_framework.mixins import UpdateModelMixin
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
-from rest_framework.views import APIView
 from rest_framework.viewsets import GenericViewSet
 from rest_framework.viewsets import ModelViewSet
 from rest_framework.viewsets import ReadOnlyModelViewSet
@@ -127,6 +132,7 @@ from documents.permissions import PaperlessObjectPermissions
 from documents.permissions import get_objects_for_user_owner_aware
 from documents.permissions import has_perms_owner_aware
 from documents.permissions import set_permissions_for_object
+from documents.schema import generate_object_with_permissions_schema
 from documents.serialisers import AcknowledgeTasksViewSerializer
 from documents.serialisers import BulkDownloadSerializer
 from documents.serialisers import BulkEditObjectsSerializer
@@ -256,6 +262,7 @@ class PermissionsAwareDocumentCountMixin(PassUserMixin):
         )
 
 
+@extend_schema_view(**generate_object_with_permissions_schema(CorrespondentSerializer))
 class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
     model = Correspondent
 
@@ -292,6 +299,7 @@ class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
         return super().retrieve(request, *args, **kwargs)
 
 
+@extend_schema_view(**generate_object_with_permissions_schema(TagSerializer))
 class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
     model = Tag
 
@@ -316,6 +324,7 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
     ordering_fields = ("color", "name", "matching_algorithm", "match", "document_count")
 
 
+@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
 class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
     model = DocumentType
 
@@ -333,6 +342,177 @@ class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
     ordering_fields = ("name", "matching_algorithm", "match", "document_count")
 
 
+@extend_schema_view(
+    retrieve=extend_schema(
+        description="Retrieve a single document",
+        responses={
+            200: DocumentSerializer(all_fields=True),
+            400: None,
+        },
+        parameters=[
+            OpenApiParameter(
+                name="full_perms",
+                type=OpenApiTypes.BOOL,
+                location=OpenApiParameter.QUERY,
+            ),
+            OpenApiParameter(
+                name="fields",
+                type=OpenApiTypes.STR,
+                many=True,
+                location=OpenApiParameter.QUERY,
+            ),
+        ],
+    ),
+    download=extend_schema(
+        description="Download the document",
+        parameters=[
+            OpenApiParameter(
+                name="original",
+                type=OpenApiTypes.BOOL,
+                location=OpenApiParameter.QUERY,
+            ),
+        ],
+        responses={200: OpenApiTypes.BINARY},
+    ),
+    history=extend_schema(
+        description="View the document history",
+        responses={
+            200: inline_serializer(
+                name="LogEntry",
+                many=True,
+                fields={
+                    "id": serializers.IntegerField(),
+                    "timestamp": serializers.DateTimeField(),
+                    "action": serializers.CharField(),
+                    "changes": serializers.DictField(),
+                    "actor": inline_serializer(
+                        name="Actor",
+                        fields={
+                            "id": serializers.IntegerField(),
+                            "username": serializers.CharField(),
+                        },
+                    ),
+                },
+            ),
+            400: None,
+            403: None,
+            404: None,
+        },
+    ),
+    metadata=extend_schema(
+        description="View the document metadata",
+        responses={
+            200: inline_serializer(
+                name="Metadata",
+                fields={
+                    "original_checksum": serializers.CharField(),
+                    "original_size": serializers.IntegerField(),
+                    "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_filename": serializers.CharField(),
+                    "archive_size": serializers.IntegerField(),
+                    "archive_metadata": serializers.DictField(),
+                    "lang": serializers.CharField(),
+                },
+            ),
+            400: None,
+            403: None,
+            404: None,
+        },
+    ),
+    notes=extend_schema(
+        description="View, add, or delete notes for the document",
+        responses={
+            200: {
+                "type": "array",
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "id": {"type": "integer"},
+                        "note": {"type": "string"},
+                        "created": {"type": "string", "format": "date-time"},
+                        "user": {
+                            "type": "object",
+                            "properties": {
+                                "id": {"type": "integer"},
+                                "username": {"type": "string"},
+                                "first_name": {"type": "string"},
+                                "last_name": {"type": "string"},
+                            },
+                        },
+                    },
+                },
+            },
+            400: None,
+            403: None,
+            404: None,
+        },
+    ),
+    suggestions=extend_schema(
+        description="View suggestions for the document",
+        responses={
+            200: inline_serializer(
+                name="Suggestions",
+                fields={
+                    "correspondents": serializers.ListField(
+                        child=serializers.IntegerField(),
+                    ),
+                    "tags": serializers.ListField(child=serializers.IntegerField()),
+                    "document_types": serializers.ListField(
+                        child=serializers.IntegerField(),
+                    ),
+                    "storage_paths": serializers.ListField(
+                        child=serializers.IntegerField(),
+                    ),
+                    "dates": serializers.ListField(child=serializers.CharField()),
+                },
+            ),
+            400: None,
+            403: None,
+            404: None,
+        },
+    ),
+    thumb=extend_schema(
+        description="View the document thumbnail",
+        responses={200: OpenApiTypes.BINARY},
+    ),
+    preview=extend_schema(
+        description="View the document preview",
+        responses={200: OpenApiTypes.BINARY},
+    ),
+    share_links=extend_schema(
+        operation_id="document_share_links",
+        description="View share links for the document",
+        parameters=[
+            OpenApiParameter(
+                name="id",
+                type=OpenApiTypes.STR,
+                location=OpenApiParameter.PATH,
+            ),
+        ],
+        responses={
+            200: {
+                "type": "array",
+                "items": {
+                    "type": "object",
+                    "properties": {
+                        "id": {"type": "integer"},
+                        "created": {"type": "string", "format": "date-time"},
+                        "expiration": {"type": "string", "format": "date-time"},
+                        "slug": {"type": "string"},
+                    },
+                },
+            },
+            400: None,
+            403: None,
+            404: None,
+        },
+    ),
+)
 class DocumentViewSet(
     PassUserMixin,
     RetrieveModelMixin,
@@ -466,7 +646,7 @@ class DocumentViewSet(
         else:
             return None
 
-    @action(methods=["get"], detail=True)
+    @action(methods=["get"], detail=True, filter_backends=[])
     @method_decorator(cache_control(no_cache=True))
     @method_decorator(
         condition(etag_func=metadata_etag, last_modified_func=metadata_last_modified),
@@ -525,7 +705,7 @@ class DocumentViewSet(
 
         return Response(meta)
 
-    @action(methods=["get"], detail=True)
+    @action(methods=["get"], detail=True, filter_backends=[])
     @method_decorator(cache_control(no_cache=True))
     @method_decorator(
         condition(
@@ -576,7 +756,7 @@ class DocumentViewSet(
 
         return Response(resp_data)
 
-    @action(methods=["get"], detail=True)
+    @action(methods=["get"], detail=True, filter_backends=[])
     @method_decorator(cache_control(no_cache=True))
     @method_decorator(
         condition(etag_func=preview_etag, last_modified_func=preview_last_modified),
@@ -588,7 +768,7 @@ class DocumentViewSet(
         except (FileNotFoundError, Document.DoesNotExist):
             raise Http404
 
-    @action(methods=["get"], detail=True)
+    @action(methods=["get"], detail=True, filter_backends=[])
     @method_decorator(cache_control(no_cache=True))
     @method_decorator(last_modified(thumbnail_last_modified))
     def thumb(self, request, pk=None):
@@ -647,6 +827,7 @@ class DocumentViewSet(
         methods=["get", "post", "delete"],
         detail=True,
         permission_classes=[PaperlessNotePermissions],
+        filter_backends=[],
     )
     def notes(self, request, pk=None):
         currentUser = request.user
@@ -754,7 +935,7 @@ class DocumentViewSet(
             },
         )
 
-    @action(methods=["get"], detail=True)
+    @action(methods=["get"], detail=True, filter_backends=[])
     def share_links(self, request, pk=None):
         currentUser = request.user
         try:
@@ -772,21 +953,16 @@ class DocumentViewSet(
 
         if request.method == "GET":
             now = timezone.now()
-            links = [
-                {
-                    "id": c.pk,
-                    "created": c.created,
-                    "expiration": c.expiration,
-                    "slug": c.slug,
-                }
-                for c in ShareLink.objects.filter(document=doc)
+            links = (
+                ShareLink.objects.filter(document=doc)
                 .only("pk", "created", "expiration", "slug")
                 .exclude(expiration__lt=now)
                 .order_by("-created")
-            ]
-            return Response(links)
+            )
+            serializer = ShareLinkSerializer(links, many=True)
+            return Response(serializer.data)
 
-    @action(methods=["get"], detail=True, name="Audit Trail")
+    @action(methods=["get"], detail=True, name="Audit Trail", filter_backends=[])
     def history(self, request, pk=None):
         if not settings.AUDIT_LOG_ENABLED:
             return HttpResponseBadRequest("Audit log is disabled")
@@ -848,6 +1024,26 @@ class DocumentViewSet(
         return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
 
 
+@extend_schema_view(
+    list=extend_schema(
+        parameters=[
+            OpenApiParameter(
+                name="full_perms",
+                type=OpenApiTypes.BOOL,
+                location=OpenApiParameter.QUERY,
+            ),
+            OpenApiParameter(
+                name="fields",
+                type=OpenApiTypes.STR,
+                many=True,
+                location=OpenApiParameter.QUERY,
+            ),
+        ],
+        responses={
+            200: DocumentSerializer(many=True, all_fields=True),
+        },
+    ),
+)
 class UnifiedSearchViewSet(DocumentViewSet):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -915,6 +1111,33 @@ class UnifiedSearchViewSet(DocumentViewSet):
         return Response(max_asn + 1)
 
 
+@extend_schema_view(
+    list=extend_schema(
+        description="Logs view",
+        responses={
+            (200, "application/json"): serializers.ListSerializer(
+                child=serializers.CharField(),
+            ),
+        },
+    ),
+    retrieve=extend_schema(
+        description="Single log view",
+        operation_id="retrieve_log",
+        parameters=[
+            OpenApiParameter(
+                name="id",
+                type=OpenApiTypes.STR,
+                location=OpenApiParameter.PATH,
+            ),
+        ],
+        responses={
+            (200, "application/json"): serializers.ListSerializer(
+                child=serializers.CharField(),
+            ),
+            (404, "application/json"): None,
+        },
+    ),
+)
 class LogViewSet(ViewSet):
     permission_classes = (IsAuthenticated, PaperlessAdminPermissions)
 
@@ -923,11 +1146,12 @@ class LogViewSet(ViewSet):
     def get_log_filename(self, log):
         return os.path.join(settings.LOGGING_DIR, f"{log}.log")
 
-    def retrieve(self, request, pk=None, *args, **kwargs):
-        if pk not in self.log_files:
+    def retrieve(self, request, *args, **kwargs):
+        log_file = kwargs.get("pk")
+        if log_file not in self.log_files:
             raise Http404
 
-        filename = self.get_log_filename(pk)
+        filename = self.get_log_filename(log_file)
 
         if not os.path.isfile(filename):
             raise Http404
@@ -964,6 +1188,24 @@ class SavedViewViewSet(ModelViewSet, PassUserMixin):
         serializer.save(owner=self.request.user)
 
 
+@extend_schema_view(
+    post=extend_schema(
+        operation_id="bulk_edit",
+        description="Perform a bulk edit operation on a list of documents",
+        external_docs={
+            "description": "Further documentation",
+            "url": "https://docs.paperless-ngx.com/api/#bulk-editing",
+        },
+        responses={
+            200: inline_serializer(
+                name="BulkEditDocumentsResult",
+                fields={
+                    "result": serializers.CharField(),
+                },
+            ),
+        },
+    ),
+)
 class BulkEditView(PassUserMixin):
     MODIFIED_FIELD_BY_METHOD = {
         "set_correspondent": "correspondent",
@@ -1113,6 +1355,18 @@ class BulkEditView(PassUserMixin):
             )
 
 
+@extend_schema_view(
+    post=extend_schema(
+        description="Upload a document via the API",
+        external_docs={
+            "description": "Further documentation",
+            "url": "https://docs.paperless-ngx.com/api/#file-uploads",
+        },
+        responses={
+            (200, "application/json"): OpenApiTypes.STR,
+        },
+    ),
+)
 class PostDocumentView(GenericAPIView):
     permission_classes = (IsAuthenticated,)
     serializer_class = PostDocumentSerializer
@@ -1169,6 +1423,63 @@ class PostDocumentView(GenericAPIView):
         return Response(async_task.id)
 
 
+@extend_schema_view(
+    post=extend_schema(
+        description="Get selection data for the selected documents",
+        responses={
+            (200, "application/json"): inline_serializer(
+                name="SelectionData",
+                fields={
+                    "selected_correspondents": serializers.ListSerializer(
+                        child=inline_serializer(
+                            name="CorrespondentCounts",
+                            fields={
+                                "id": serializers.IntegerField(),
+                                "document_count": serializers.IntegerField(),
+                            },
+                        ),
+                    ),
+                    "selected_tags": serializers.ListSerializer(
+                        child=inline_serializer(
+                            name="TagCounts",
+                            fields={
+                                "id": serializers.IntegerField(),
+                                "document_count": serializers.IntegerField(),
+                            },
+                        ),
+                    ),
+                    "selected_document_types": serializers.ListSerializer(
+                        child=inline_serializer(
+                            name="DocumentTypeCounts",
+                            fields={
+                                "id": serializers.IntegerField(),
+                                "document_count": serializers.IntegerField(),
+                            },
+                        ),
+                    ),
+                    "selected_storage_paths": serializers.ListSerializer(
+                        child=inline_serializer(
+                            name="StoragePathCounts",
+                            fields={
+                                "id": serializers.IntegerField(),
+                                "document_count": serializers.IntegerField(),
+                            },
+                        ),
+                    ),
+                    "selected_custom_fields": serializers.ListSerializer(
+                        child=inline_serializer(
+                            name="CustomFieldCounts",
+                            fields={
+                                "id": serializers.IntegerField(),
+                                "document_count": serializers.IntegerField(),
+                            },
+                        ),
+                    ),
+                },
+            ),
+        },
+    ),
+)
 class SelectionDataView(GenericAPIView):
     permission_classes = (IsAuthenticated,)
     serializer_class = DocumentListSerializer
@@ -1242,7 +1553,31 @@ class SelectionDataView(GenericAPIView):
         return r
 
 
-class SearchAutoCompleteView(APIView):
+@extend_schema_view(
+    get=extend_schema(
+        description="Get a list of all available tags",
+        parameters=[
+            OpenApiParameter(
+                name="term",
+                required=False,
+                type=str,
+                description="Term to search for",
+            ),
+            OpenApiParameter(
+                name="limit",
+                required=False,
+                type=int,
+                description="Number of completions to return",
+            ),
+        ],
+        responses={
+            (200, "application/json"): serializers.ListSerializer(
+                child=serializers.CharField(),
+            ),
+        },
+    ),
+)
+class SearchAutoCompleteView(GenericAPIView):
     permission_classes = (IsAuthenticated,)
 
     def get(self, request, format=None):
@@ -1274,6 +1609,45 @@ class SearchAutoCompleteView(APIView):
         )
 
 
+@extend_schema_view(
+    get=extend_schema(
+        description="Global search",
+        parameters=[
+            OpenApiParameter(
+                name="query",
+                required=True,
+                type=str,
+                description="Query to search for",
+            ),
+            OpenApiParameter(
+                name="db_only",
+                required=False,
+                type=bool,
+                description="Search only the database",
+            ),
+        ],
+        responses={
+            (200, "application/json"): inline_serializer(
+                name="SearchResult",
+                fields={
+                    "total": serializers.IntegerField(),
+                    "documents": DocumentSerializer(many=True),
+                    "saved_views": SavedViewSerializer(many=True),
+                    "tags": TagSerializer(many=True),
+                    "correspondents": CorrespondentSerializer(many=True),
+                    "document_types": DocumentTypeSerializer(many=True),
+                    "storage_paths": StoragePathSerializer(many=True),
+                    "users": UserSerializer(many=True),
+                    "groups": GroupSerializer(many=True),
+                    "mail_rules": MailRuleSerializer(many=True),
+                    "mail_accounts": MailAccountSerializer(many=True),
+                    "workflows": WorkflowSerializer(many=True),
+                    "custom_fields": CustomFieldSerializer(many=True),
+                },
+            ),
+        },
+    ),
+)
 class GlobalSearchView(PassUserMixin):
     permission_classes = (IsAuthenticated,)
     serializer_class = SearchResultSerializer
@@ -1469,7 +1843,15 @@ class GlobalSearchView(PassUserMixin):
         )
 
 
-class StatisticsView(APIView):
+@extend_schema_view(
+    get=extend_schema(
+        description="Get statistics for the current user",
+        responses={
+            (200, "application/json"): OpenApiTypes.OBJECT,
+        },
+    ),
+)
+class StatisticsView(GenericAPIView):
     permission_classes = (IsAuthenticated,)
 
     def get(self, request, format=None):
@@ -1623,6 +2005,7 @@ class BulkDownloadView(GenericAPIView):
             return response
 
 
+@extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer))
 class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
     model = StoragePath
 
@@ -1762,6 +2145,14 @@ class UiSettingsView(GenericAPIView):
         )
 
 
+@extend_schema_view(
+    get=extend_schema(
+        description="Get the current version of the Paperless-NGX server",
+        responses={
+            (200, "application/json"): OpenApiTypes.OBJECT,
+        },
+    ),
+)
 class RemoteVersionView(GenericAPIView):
     def get(self, request, format=None):
         remote_version = "0.0.0"
@@ -1802,6 +2193,33 @@ class RemoteVersionView(GenericAPIView):
         )
 
 
+@extend_schema_view(
+    acknowledge=extend_schema(
+        operation_id="acknowledge_tasks",
+        description="Acknowledge a list of tasks",
+        request={
+            "application/json": {
+                "type": "object",
+                "properties": {
+                    "tasks": {
+                        "type": "array",
+                        "items": {"type": "integer"},
+                    },
+                },
+                "required": ["tasks"],
+            },
+        },
+        responses={
+            (200, "application/json"): inline_serializer(
+                name="AcknowledgeTasks",
+                fields={
+                    "result": serializers.IntegerField(),
+                },
+            ),
+            (400, "application/json"): None,
+        },
+    ),
+)
 class TasksViewSet(ReadOnlyModelViewSet):
     permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
     serializer_class = TasksViewSerializer
@@ -1907,6 +2325,24 @@ def serve_file(*, doc: Document, use_archive: bool, disposition: str):
     return response
 
 
+@extend_schema_view(
+    post=extend_schema(
+        operation_id="bulk_edit_objects",
+        description="Perform a bulk edit operation on a list of objects",
+        external_docs={
+            "description": "Further documentation",
+            "url": "https://docs.paperless-ngx.com/api/#objects",
+        },
+        responses={
+            200: inline_serializer(
+                name="BulkEditResult",
+                fields={
+                    "result": serializers.CharField(),
+                },
+            ),
+        },
+    ),
+)
 class BulkEditObjectsView(PassUserMixin):
     permission_classes = (IsAuthenticated,)
     serializer_class = BulkEditObjectsSerializer
@@ -2065,6 +2501,71 @@ class CustomFieldViewSet(ModelViewSet):
         )
 
 
+@extend_schema_view(
+    get=extend_schema(
+        description="Get the current system status of the Paperless-NGX server",
+        responses={
+            (200, "application/json"): inline_serializer(
+                name="SystemStatus",
+                fields={
+                    "pngx_version": serializers.CharField(),
+                    "server_os": serializers.CharField(),
+                    "install_type": serializers.CharField(),
+                    "storage": inline_serializer(
+                        name="Storage",
+                        fields={
+                            "total": serializers.IntegerField(),
+                            "available": serializers.IntegerField(),
+                        },
+                    ),
+                    "database": inline_serializer(
+                        name="Database",
+                        fields={
+                            "type": serializers.CharField(),
+                            "url": serializers.CharField(),
+                            "status": serializers.CharField(),
+                            "error": serializers.CharField(),
+                            "migration_status": inline_serializer(
+                                name="MigrationStatus",
+                                fields={
+                                    "latest_migration": serializers.CharField(),
+                                    "unapplied_migrations": serializers.ListSerializer(
+                                        child=serializers.CharField(),
+                                    ),
+                                },
+                            ),
+                        },
+                    ),
+                    "tasks": inline_serializer(
+                        name="Tasks",
+                        fields={
+                            "redis_url": serializers.CharField(),
+                            "redis_status": serializers.CharField(),
+                            "redis_error": serializers.CharField(),
+                            "celery_status": serializers.CharField(),
+                        },
+                    ),
+                    "index": inline_serializer(
+                        name="Index",
+                        fields={
+                            "status": serializers.CharField(),
+                            "error": serializers.CharField(),
+                            "last_modified": serializers.DateTimeField(),
+                        },
+                    ),
+                    "classifier": inline_serializer(
+                        name="Classifier",
+                        fields={
+                            "status": serializers.CharField(),
+                            "error": serializers.CharField(),
+                            "last_trained": serializers.DateTimeField(),
+                        },
+                    ),
+                },
+            ),
+        },
+    ),
+)
 class SystemStatusView(PassUserMixin):
     permission_classes = (IsAuthenticated,)
 
index cd9325c099c12c5245943b348a0926f87f735430..461eef587230ae1ce6d0e329e76612d2631a29ef 100644 (file)
@@ -11,22 +11,11 @@ from rest_framework import serializers
 from rest_framework.authtoken.serializers import AuthTokenSerializer
 
 from paperless.models import ApplicationConfiguration
+from paperless_mail.serialisers import ObfuscatedPasswordField
 
 logger = logging.getLogger("paperless.settings")
 
 
-class ObfuscatedUserPasswordField(serializers.Field):
-    """
-    Sends *** string instead of password in the clear
-    """
-
-    def to_representation(self, value):
-        return "**********" if len(value) > 0 else ""
-
-    def to_internal_value(self, data):
-        return data
-
-
 class PaperlessAuthTokenSerializer(AuthTokenSerializer):
     code = serializers.CharField(
         label="MFA Code",
@@ -58,7 +47,7 @@ class PaperlessAuthTokenSerializer(AuthTokenSerializer):
 
 
 class UserSerializer(serializers.ModelSerializer):
-    password = ObfuscatedUserPasswordField(required=False)
+    password = ObfuscatedPasswordField(required=False)
     user_permissions = serializers.SlugRelatedField(
         many=True,
         queryset=Permission.objects.exclude(content_type__app_label="admin"),
@@ -68,7 +57,7 @@ class UserSerializer(serializers.ModelSerializer):
     inherited_permissions = serializers.SerializerMethodField()
     is_mfa_enabled = serializers.SerializerMethodField()
 
-    def get_is_mfa_enabled(self, user: User):
+    def get_is_mfa_enabled(self, user: User) -> bool:
         mfa_adapter = get_mfa_adapter()
         return mfa_adapter.is_mfa_enabled(user)
 
@@ -91,7 +80,7 @@ class UserSerializer(serializers.ModelSerializer):
             "is_mfa_enabled",
         )
 
-    def get_inherited_permissions(self, obj):
+    def get_inherited_permissions(self, obj) -> list[str]:
         return obj.get_group_permissions()
 
     def update(self, instance, validated_data):
@@ -157,13 +146,13 @@ class SocialAccountSerializer(serializers.ModelSerializer):
             "name",
         )
 
-    def get_name(self, obj):
+    def get_name(self, obj) -> str:
         return obj.get_provider_account().to_str()
 
 
 class ProfileSerializer(serializers.ModelSerializer):
     email = serializers.EmailField(allow_blank=True, required=False)
-    password = ObfuscatedUserPasswordField(required=False, allow_null=False)
+    password = ObfuscatedPasswordField(required=False, allow_null=False)
     auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key")
     social_accounts = SocialAccountSerializer(
         many=True,
@@ -171,11 +160,15 @@ class ProfileSerializer(serializers.ModelSerializer):
         source="socialaccount_set",
     )
     is_mfa_enabled = serializers.SerializerMethodField()
+    has_usable_password = serializers.SerializerMethodField()
 
-    def get_is_mfa_enabled(self, user: User):
+    def get_is_mfa_enabled(self, user: User) -> bool:
         mfa_adapter = get_mfa_adapter()
         return mfa_adapter.is_mfa_enabled(user)
 
+    def get_has_usable_password(self, user: User) -> bool:
+        return user.has_usable_password()
+
     class Meta:
         model = User
         fields = (
index 846b9e0ee85d1de622e8e2ca01e424d8a277791f..6a5e98f46de55770c5ff39d30913f67378360a0d 100644 (file)
@@ -328,6 +328,8 @@ INSTALLED_APPS = [
     "allauth.account",
     "allauth.socialaccount",
     "allauth.mfa",
+    "drf_spectacular",
+    "drf_spectacular_sidecar",
     *env_apps,
 ]
 
@@ -345,6 +347,25 @@ REST_FRAMEWORK = {
     # Make sure these are ordered and that the most recent version appears
     # last. See api.md#api-versioning when adding new versions.
     "ALLOWED_VERSIONS": ["1", "2", "3", "4", "5", "6", "7"],
+    # DRF Spectacular default schema
+    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
+}
+
+# DRF Spectacular settings
+SPECTACULAR_SETTINGS = {
+    "TITLE": "Paperless-ngx REST API",
+    "DESCRIPTION": "OpenAPI Spec for Paperless-ngx",
+    "VERSION": "6.0.0",
+    "SERVE_INCLUDE_SCHEMA": False,
+    "SWAGGER_UI_DIST": "SIDECAR",
+    "COMPONENT_SPLIT_REQUEST": True,
+    "EXTERNAL_DOCS": {
+        "description": "Paperless-ngx API Documentation",
+        "url": "https://docs.paperless-ngx.com/api/",
+    },
+    "ENUM_NAME_OVERRIDES": {
+        "MatchingAlgorithm": "documents.models.MatchingModel.MATCHING_ALGORITHMS",
+    },
 }
 
 if DEBUG:
index 703a72042af85245bca5bf58ce96c59dc9385901..e5a6065bef3c72cdde83d558aa628c6dc8c15fdd 100644 (file)
@@ -14,6 +14,8 @@ from django.utils.translation import gettext_lazy as _
 from django.views.decorators.csrf import ensure_csrf_cookie
 from django.views.generic import RedirectView
 from django.views.static import serve
+from drf_spectacular.views import SpectacularAPIView
+from drf_spectacular.views import SpectacularSwaggerView
 from rest_framework.routers import DefaultRouter
 
 from documents.views import BulkDownloadView
@@ -203,6 +205,27 @@ urlpatterns = [
                     OauthCallbackView.as_view(),
                     name="oauth_callback",
                 ),
+                re_path(
+                    "^schema/",
+                    include(
+                        [
+                            re_path(
+                                "^$",
+                                SpectacularAPIView.as_view(),
+                                name="schema",
+                            ),
+                            re_path(
+                                "^view/",
+                                SpectacularSwaggerView.as_view(),
+                                name="swagger-ui",
+                            ),
+                        ],
+                    ),
+                ),
+                re_path(
+                    "^$",  # Redirect to the API swagger view
+                    RedirectView.as_view(url="schema/view/"),
+                ),
                 *api_router.urls,
             ],
         ),
index 6d297c49bdeeecfbd1444c7cb7a1912909b13df8..fdd7c21a49e1c8f8ab575609c90e15c385e9fc9f 100644 (file)
@@ -18,6 +18,9 @@ from django.http import HttpResponseForbidden
 from django.http import HttpResponseNotFound
 from django.views.generic import View
 from django_filters.rest_framework import DjangoFilterBackend
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema
+from drf_spectacular.utils import extend_schema_view
 from rest_framework.authtoken.models import Token
 from rest_framework.authtoken.views import ObtainAuthToken
 from rest_framework.decorators import action
@@ -27,7 +30,6 @@ from rest_framework.pagination import PageNumberPagination
 from rest_framework.permissions import DjangoModelPermissions
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
-from rest_framework.views import APIView
 from rest_framework.viewsets import ModelViewSet
 
 from documents.permissions import PaperlessObjectPermissions
@@ -197,6 +199,34 @@ class ProfileView(GenericAPIView):
         return Response(serializer.to_representation(user))
 
 
+@extend_schema_view(
+    get=extend_schema(
+        responses={
+            (200, "application/json"): OpenApiTypes.OBJECT,
+        },
+    ),
+    post=extend_schema(
+        request={
+            "application/json": {
+                "type": "object",
+                "properties": {
+                    "secret": {"type": "string"},
+                    "code": {"type": "string"},
+                },
+                "required": ["secret", "code"],
+            },
+        },
+        responses={
+            (200, "application/json"): OpenApiTypes.OBJECT,
+        },
+    ),
+    delete=extend_schema(
+        responses={
+            (200, "application/json"): OpenApiTypes.BOOL,
+            404: OpenApiTypes.STR,
+        },
+    ),
+)
 class TOTPView(GenericAPIView):
     """
     TOTP views
@@ -267,6 +297,16 @@ class TOTPView(GenericAPIView):
             return HttpResponseNotFound("TOTP not found")
 
 
+@extend_schema_view(
+    post=extend_schema(
+        request={
+            "application/json": None,
+        },
+        responses={
+            (200, "application/json"): OpenApiTypes.STR,
+        },
+    ),
+)
 class GenerateAuthTokenView(GenericAPIView):
     """
     Generates (or re-generates) an auth token, requires a logged in user
@@ -287,6 +327,15 @@ class GenerateAuthTokenView(GenericAPIView):
         )
 
 
+@extend_schema_view(
+    list=extend_schema(
+        description="Get the application configuration",
+        external_docs={
+            "description": "Application Configuration",
+            "url": "https://docs.paperless-ngx.com/configuration/",
+        },
+    ),
+)
 class ApplicationConfigurationViewSet(ModelViewSet):
     model = ApplicationConfiguration
 
@@ -296,6 +345,23 @@ class ApplicationConfigurationViewSet(ModelViewSet):
     permission_classes = (IsAuthenticated, DjangoModelPermissions)
 
 
+@extend_schema_view(
+    post=extend_schema(
+        request={
+            "application/json": {
+                "type": "object",
+                "properties": {
+                    "id": {"type": "integer"},
+                },
+                "required": ["id"],
+            },
+        },
+        responses={
+            (200, "application/json"): OpenApiTypes.INT,
+            400: OpenApiTypes.STR,
+        },
+    ),
+)
 class DisconnectSocialAccountView(GenericAPIView):
     """
     Disconnects a social account provider from the user account
@@ -315,7 +381,14 @@ class DisconnectSocialAccountView(GenericAPIView):
             return HttpResponseBadRequest("Social account not found")
 
 
-class SocialAccountProvidersView(APIView):
+@extend_schema_view(
+    get=extend_schema(
+        responses={
+            (200, "application/json"): OpenApiTypes.OBJECT,
+        },
+    ),
+)
+class SocialAccountProvidersView(GenericAPIView):
     """
     List of social account providers
     """
index e9836b421d2437357f9e184a485114f8e1ec5c17..c7a20acbf93bd04977d63dbfa3435f410cf35691 100644 (file)
@@ -8,13 +8,13 @@ from paperless_mail.models import MailAccount
 from paperless_mail.models import MailRule
 
 
-class ObfuscatedPasswordField(serializers.Field):
+class ObfuscatedPasswordField(serializers.CharField):
     """
     Sends *** string instead of password in the clear
     """
 
-    def to_representation(self, value):
-        return "*" * len(value)
+    def to_representation(self, value) -> str:
+        return "*" * max(10, len(value))
 
     def to_internal_value(self, data):
         return data
index 7e9bbfe849da784694281630f1523d521793e7f9..985ed006b08c8672cb3426eb2fd3b397b74eaeae 100644 (file)
@@ -64,7 +64,7 @@ class TestAPIMailAccounts(DirectoriesMixin, APITestCase):
         self.assertEqual(returned_account1["username"], account1.username)
         self.assertEqual(
             returned_account1["password"],
-            "*" * len(account1.password),
+            "**********",
         )
         self.assertEqual(returned_account1["imap_server"], account1.imap_server)
         self.assertEqual(returned_account1["imap_port"], account1.imap_port)
index 1b596452fe8b767bdeeb1838b516feafa0f40aa2..d286843c9eebeb1df1069ebc3d2ac28a957f485a 100644 (file)
@@ -5,7 +5,12 @@ from datetime import timedelta
 from django.http import HttpResponseBadRequest
 from django.http import HttpResponseRedirect
 from django.utils import timezone
+from drf_spectacular.types import OpenApiTypes
+from drf_spectacular.utils import extend_schema
+from drf_spectacular.utils import extend_schema_view
+from drf_spectacular.utils import inline_serializer
 from httpx_oauth.oauth2 import GetAccessTokenError
+from rest_framework import serializers
 from rest_framework.decorators import action
 from rest_framework.generics import GenericAPIView
 from rest_framework.permissions import IsAuthenticated
@@ -27,6 +32,19 @@ from paperless_mail.serialisers import MailRuleSerializer
 from paperless_mail.tasks import process_mail_accounts
 
 
+@extend_schema_view(
+    test=extend_schema(
+        operation_id="mail_account_test",
+        description="Test a mail account",
+        responses={
+            200: inline_serializer(
+                name="MailAccountTestResponse",
+                fields={"success": serializers.BooleanField()},
+            ),
+            400: OpenApiTypes.STR,
+        },
+    ),
+)
 class MailAccountViewSet(ModelViewSet, PassUserMixin):
     model = MailAccount
 
@@ -106,6 +124,12 @@ class MailRuleViewSet(ModelViewSet, PassUserMixin):
     filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
 
 
+@extend_schema_view(
+    get=extend_schema(
+        description="Callback view for OAuth2 authentication",
+        responses={200: None},
+    ),
+)
 class OauthCallbackView(GenericAPIView):
     permission_classes = (IsAuthenticated,)