mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Feature: openapi spec, full api browser (#8948)
This commit is contained in:
parent
c316ae369b
commit
1dc80f04cb
2
Pipfile
2
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 = "*"}
|
||||
|
164
Pipfile.lock
generated
164
Pipfile.lock
generated
@ -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",
|
||||
|
182
docs/api.md
182
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://<paperless-host>:<port>/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/<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
|
||||
|
@ -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
|
||||
],
|
||||
|
@ -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,
|
||||
],
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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):
|
||||
"""
|
||||
|
44
src/documents/schema.py
Normal file
44
src/documents/schema.py
Normal file
@ -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"]
|
||||
}
|
@ -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:
|
||||
|
@ -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)
|
||||
|
||||
|
27
src/documents/tests/test_api_schema.py
Normal file
27
src/documents/tests/test_api_schema.py
Normal file
@ -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)
|
@ -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,)
|
||||
|
||||
|
@ -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 = (
|
||||
|
@ -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:
|
||||
|
@ -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,
|
||||
],
|
||||
),
|
||||
|
@ -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
|
||||
"""
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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,)
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user