From 1dc80f04cbefeafbc10bb2aafb93a91c52d1c66c Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 10 Feb 2025 08:43:07 -0800 Subject: [PATCH] Feature: openapi spec, full api browser (#8948) --- Pipfile | 2 + Pipfile.lock | 164 +++++- docs/api.md | 182 +----- ...om-fields-query-dropdown.component.spec.ts | 3 + src-ui/src/app/data/custom-field-query.ts | 10 +- src/documents/apps.py | 2 + src/documents/filters.py | 6 + src/documents/schema.py | 44 ++ src/documents/serialisers.py | 132 ++++- src/documents/tests/test_api_permissions.py | 4 +- src/documents/tests/test_api_schema.py | 27 + src/documents/views.py | 545 +++++++++++++++++- src/paperless/serialisers.py | 29 +- src/paperless/settings.py | 21 + src/paperless/urls.py | 23 + src/paperless/views.py | 77 ++- src/paperless_mail/serialisers.py | 6 +- src/paperless_mail/tests/test_api.py | 2 +- src/paperless_mail/views.py | 24 + 19 files changed, 1048 insertions(+), 255 deletions(-) create mode 100644 src/documents/schema.py create mode 100644 src/documents/tests/test_api_schema.py diff --git a/Pipfile b/Pipfile index 1e69f316d..863b157ec 100644 --- 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 = "*"} diff --git a/Pipfile.lock b/Pipfile.lock index 1f74e6708..aed779ecb 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6a7869231917d0cf6f5852520b5cb9b0df3802ed162b1a8107d0b1e1c37f0535" + "sha256": "9fdd406708b9c0693041c0506a29b7ab83ce196460ee299bfc764f1e03603e1a" }, "pipfile-spec": 6, "requires": {}, @@ -46,6 +46,14 @@ "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", @@ -613,6 +621,24 @@ "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" @@ -927,6 +953,14 @@ ], "version": "==0.5.1" }, + "inflection": { + "hashes": [ + "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", + "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2" + ], + "markers": "python_version >= '3.5'", + "version": "==0.5.1" + }, "inotify-simple": { "hashes": [ "sha256:8440ffe49c4ae81a8df57c1ae1eb4b6bfa7acb830099bfb3e305b383005cc128" @@ -960,6 +994,22 @@ "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", @@ -1893,6 +1943,14 @@ "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", @@ -2017,6 +2075,102 @@ "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", @@ -2283,6 +2437,14 @@ "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", diff --git a/docs/api.md b/docs/api.md index 050443c19..9c28476c4 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1,148 +1,10 @@ # 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://:/api/`. This also documents -most of the available filters and ordering fields. +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/`. -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 }` - -!!! 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//download/`: Download the document. -- `/api/documents//preview/`: Display the document inline, without - downloading it. -- `/api/documents//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//preview`, - `/fetch//thumb` and `/fetch//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//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//notes/`: Retrieve notes for a document. -- `/api/documents//share_links/`: Retrieve share links for a document. -- `/api/documents//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 diff --git a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts index 7afb5fc1c..4dcbceb13 100644 --- a/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts +++ b/src-ui/src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component.spec.ts @@ -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 ], diff --git a/src-ui/src/app/data/custom-field-query.ts b/src-ui/src/app/data/custom-field-query.ts index 226a10605..084b7a330 100644 --- a/src-ui/src/app/data/custom-field-query.ts +++ b/src-ui/src/app/data/custom-field-query.ts @@ -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, ], } diff --git a/src/documents/apps.py b/src/documents/apps.py index ac1bb21eb..f3b798c0b 100644 --- a/src/documents/apps.py +++ b/src/documents/apps.py @@ -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) diff --git a/src/documents/filters.py b/src/documents/filters.py index 21a9422ad..1ce782ee6 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -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 index 000000000..2ab421a15 --- /dev/null +++ b/src/documents/schema.py @@ -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"] + } diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 4adadbcb2..6a0a1eec1 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -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: diff --git a/src/documents/tests/test_api_permissions.py b/src/documents/tests/test_api_permissions.py index 3785c8f2a..637bd1fe0 100644 --- a/src/documents/tests/test_api_permissions.py +++ b/src/documents/tests/test_api_permissions.py @@ -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 index 000000000..fc2e0fdf3 --- /dev/null +++ b/src/documents/tests/test_api_schema.py @@ -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) diff --git a/src/documents/views.py b/src/documents/views.py index f23c1b953..a856883f3 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -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,) diff --git a/src/paperless/serialisers.py b/src/paperless/serialisers.py index cd9325c09..461eef587 100644 --- a/src/paperless/serialisers.py +++ b/src/paperless/serialisers.py @@ -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 = ( diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 846b9e0ee..6a5e98f46 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -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: diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 703a72042..e5a6065be 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -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, ], ), diff --git a/src/paperless/views.py b/src/paperless/views.py index 6d297c49b..fdd7c21a4 100644 --- a/src/paperless/views.py +++ b/src/paperless/views.py @@ -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 """ diff --git a/src/paperless_mail/serialisers.py b/src/paperless_mail/serialisers.py index e9836b421..c7a20acbf 100644 --- a/src/paperless_mail/serialisers.py +++ b/src/paperless_mail/serialisers.py @@ -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 diff --git a/src/paperless_mail/tests/test_api.py b/src/paperless_mail/tests/test_api.py index 7e9bbfe84..985ed006b 100644 --- a/src/paperless_mail/tests/test_api.py +++ b/src/paperless_mail/tests/test_api.py @@ -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) diff --git a/src/paperless_mail/views.py b/src/paperless_mail/views.py index 1b596452f..d286843c9 100644 --- a/src/paperless_mail/views.py +++ b/src/paperless_mail/views.py @@ -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,)