Feature: openapi spec, full api browser (#8948)

This commit is contained in:
shamoon 2025-02-10 08:43:07 -08:00 committed by GitHub
parent c316ae369b
commit 1dc80f04cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1048 additions and 255 deletions

View File

@ -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
View File

@ -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",

View File

@ -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

View File

@ -113,6 +113,9 @@ describe('CustomFieldsQueryDropdownComponent', () => {
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.Basic
],
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.Exact
],
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.String
],

View File

@ -36,6 +36,7 @@ export const CUSTOM_FIELD_QUERY_OPERATOR_LABELS = {
export enum CustomFieldQueryOperatorGroups {
Basic = 'basic',
Exact = 'exact',
String = 'string',
Arithmetic = 'arithmetic',
Containment = 'containment',
@ -48,8 +49,8 @@ export const CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP = {
[CustomFieldQueryOperatorGroups.Basic]: [
CustomFieldQueryOperator.Exists,
CustomFieldQueryOperator.IsNull,
CustomFieldQueryOperator.Exact,
],
[CustomFieldQueryOperatorGroups.Exact]: [CustomFieldQueryOperator.Exact],
[CustomFieldQueryOperatorGroups.String]: [CustomFieldQueryOperator.IContains],
[CustomFieldQueryOperatorGroups.Arithmetic]: [
CustomFieldQueryOperator.GreaterThan,
@ -71,27 +72,33 @@ export const CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP = {
export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
[CustomFieldDataType.String]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Exact,
CustomFieldQueryOperatorGroups.String,
],
[CustomFieldDataType.Url]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Exact,
CustomFieldQueryOperatorGroups.String,
],
[CustomFieldDataType.Date]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Exact,
CustomFieldQueryOperatorGroups.Date,
],
[CustomFieldDataType.Boolean]: [CustomFieldQueryOperatorGroups.Basic],
[CustomFieldDataType.Integer]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Exact,
CustomFieldQueryOperatorGroups.Arithmetic,
],
[CustomFieldDataType.Float]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Exact,
CustomFieldQueryOperatorGroups.Arithmetic,
],
[CustomFieldDataType.Monetary]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Exact,
CustomFieldQueryOperatorGroups.String,
CustomFieldQueryOperatorGroups.Arithmetic,
],
@ -101,6 +108,7 @@ export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
],
[CustomFieldDataType.Select]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Exact,
CustomFieldQueryOperatorGroups.Subset,
],
}

View File

@ -28,4 +28,6 @@ class DocumentsConfig(AppConfig):
document_consumption_finished.connect(run_workflows_added)
document_updated.connect(run_workflows_updated)
import documents.schema # noqa: F401
AppConfig.ready(self)

View File

@ -22,6 +22,7 @@ from django.utils.translation import gettext_lazy as _
from django_filters.rest_framework import BooleanFilter
from django_filters.rest_framework import Filter
from django_filters.rest_framework import FilterSet
from drf_spectacular.utils import extend_schema_field
from guardian.utils import get_group_obj_perms_model
from guardian.utils import get_user_obj_perms_model
from rest_framework import serializers
@ -124,6 +125,7 @@ class ObjectFilter(Filter):
return qs
@extend_schema_field(serializers.BooleanField)
class InboxFilter(Filter):
def filter(self, qs, value):
if value == "true":
@ -134,6 +136,7 @@ class InboxFilter(Filter):
return qs
@extend_schema_field(serializers.CharField)
class TitleContentFilter(Filter):
def filter(self, qs, value):
if value:
@ -142,6 +145,7 @@ class TitleContentFilter(Filter):
return qs
@extend_schema_field(serializers.BooleanField)
class SharedByUser(Filter):
def filter(self, qs, value):
ctype = ContentType.objects.get_for_model(self.model)
@ -186,6 +190,7 @@ class CustomFieldFilterSet(FilterSet):
}
@extend_schema_field(serializers.CharField)
class CustomFieldsFilter(Filter):
def filter(self, qs, value):
if value:
@ -642,6 +647,7 @@ class CustomFieldQueryParser:
self._current_depth -= 1
@extend_schema_field(serializers.CharField)
class CustomFieldQueryFilter(Filter):
def __init__(self, validation_prefix):
"""

44
src/documents/schema.py Normal file
View 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"]
}

View File

@ -19,6 +19,7 @@ from django.core.validators import integer_validator
from django.utils.crypto import get_random_string
from django.utils.text import slugify
from django.utils.translation import gettext as _
from drf_spectacular.utils import extend_schema_field
from drf_writable_nested.serializers import NestedUpdateMixin
from guardian.core import ObjectPermissionChecker
from guardian.shortcuts import get_users_with_perms
@ -86,7 +87,7 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
class MatchingModelSerializer(serializers.ModelSerializer):
document_count = serializers.IntegerField(read_only=True)
def get_slug(self, obj):
def get_slug(self, obj) -> str:
return slugify(obj.name)
slug = SerializerMethodField()
@ -179,9 +180,47 @@ class SerializerWithPerms(serializers.Serializer):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user", None)
self.full_perms = kwargs.pop("full_perms", False)
self.all_fields = kwargs.pop("all_fields", False)
super().__init__(*args, **kwargs)
@extend_schema_field(
field={
"type": "object",
"properties": {
"view": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
"change": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
},
},
)
class SetPermissionsSerializer(serializers.DictField):
pass
class OwnedObjectSerializer(
SerializerWithPerms,
serializers.ModelSerializer,
@ -190,16 +229,50 @@ class OwnedObjectSerializer(
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
try:
if self.full_perms:
self.fields.pop("user_can_change")
self.fields.pop("is_shared_by_requester")
else:
self.fields.pop("permissions")
except KeyError:
pass
if not self.all_fields:
try:
if self.full_perms:
self.fields.pop("user_can_change")
self.fields.pop("is_shared_by_requester")
else:
self.fields.pop("permissions")
except KeyError:
pass
def get_permissions(self, obj):
@extend_schema_field(
field={
"type": "object",
"properties": {
"view": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
"change": {
"type": "object",
"properties": {
"users": {
"type": "array",
"items": {"type": "integer"},
},
"groups": {
"type": "array",
"items": {"type": "integer"},
},
},
},
},
},
)
def get_permissions(self, obj) -> dict:
view_codename = f"view_{obj.__class__.__name__.lower()}"
change_codename = f"change_{obj.__class__.__name__.lower()}"
@ -228,7 +301,7 @@ class OwnedObjectSerializer(
},
}
def get_user_can_change(self, obj):
def get_user_can_change(self, obj) -> bool:
checker = ObjectPermissionChecker(self.user) if self.user is not None else None
return (
obj.owner is None
@ -271,7 +344,7 @@ class OwnedObjectSerializer(
return set(user_permission_pks) | set(group_permission_pks)
def get_is_shared_by_requester(self, obj: Document):
def get_is_shared_by_requester(self, obj: Document) -> bool:
# First check the context to see if `shared_object_pks` is set by the parent.
shared_object_pks = self.context.get("shared_object_pks")
# If not just check if the current object is shared.
@ -283,7 +356,7 @@ class OwnedObjectSerializer(
user_can_change = SerializerMethodField(read_only=True)
is_shared_by_requester = SerializerMethodField(read_only=True)
set_permissions = serializers.DictField(
set_permissions = SetPermissionsSerializer(
label="Set permissions",
allow_empty=True,
required=False,
@ -380,7 +453,7 @@ class DocumentTypeSerializer(MatchingModelSerializer, OwnedObjectSerializer):
)
class ColorField(serializers.Field):
class DeprecatedColors:
COLOURS = (
(1, "#a6cee3"),
(2, "#1f78b4"),
@ -397,14 +470,21 @@ class ColorField(serializers.Field):
(13, "#cccccc"),
)
@extend_schema_field(
serializers.ChoiceField(
choices=DeprecatedColors.COLOURS,
),
)
class ColorField(serializers.Field):
def to_internal_value(self, data):
for id, color in self.COLOURS:
for id, color in DeprecatedColors.COLOURS:
if id == data:
return color
raise serializers.ValidationError
def to_representation(self, value):
for id, color in self.COLOURS:
for id, color in DeprecatedColors.COLOURS:
if color == value:
return id
return 1
@ -433,7 +513,7 @@ class TagSerializerVersion1(MatchingModelSerializer, OwnedObjectSerializer):
class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
def get_text_color(self, obj):
def get_text_color(self, obj) -> str:
try:
h = obj.color.lstrip("#")
rgb = tuple(int(h[i : i + 2], 16) / 256 for i in (0, 2, 4))
@ -499,7 +579,7 @@ class CustomFieldSerializer(serializers.ModelSerializer):
context = kwargs.get("context")
self.api_version = int(
context.get("request").version
if context.get("request")
if context and context.get("request")
else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
)
super().__init__(*args, **kwargs)
@ -657,7 +737,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
)
return instance
def get_value(self, obj: CustomFieldInstance):
def get_value(self, obj: CustomFieldInstance) -> str | int | float | dict | None:
return obj.value
def validate(self, data):
@ -808,13 +888,13 @@ class DocumentSerializer(
required=False,
)
def get_page_count(self, obj):
def get_page_count(self, obj) -> int | None:
return obj.page_count
def get_original_file_name(self, obj):
def get_original_file_name(self, obj) -> str | None:
return obj.original_filename
def get_archived_file_name(self, obj):
def get_archived_file_name(self, obj) -> str | None:
if obj.has_archive_version:
return obj.get_public_filename(archive=True)
else:
@ -911,7 +991,7 @@ class DocumentSerializer(
# return full permissions if we're doing a PATCH or PUT
context = kwargs.get("context")
if (
if context is not None and (
context.get("request").method == "PATCH"
or context.get("request").method == "PUT"
):
@ -921,7 +1001,6 @@ class DocumentSerializer(
class Meta:
model = Document
depth = 1
fields = (
"id",
"correspondent",
@ -1606,7 +1685,6 @@ class UiSettingsViewSerializer(serializers.ModelSerializer):
class TasksViewSerializer(OwnedObjectSerializer):
class Meta:
model = PaperlessTask
depth = 1
fields = (
"id",
"task_id",
@ -1623,7 +1701,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
type = serializers.SerializerMethodField()
def get_type(self, obj):
def get_type(self, obj) -> str:
# just file tasks, for now
return "file"
@ -1631,7 +1709,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
created_doc_re = re.compile(r"New document id (\d+) created")
duplicate_doc_re = re.compile(r"It is a duplicate of .* \(#(\d+)\)")
def get_related_document(self, obj):
def get_related_document(self, obj) -> str | None:
result = None
re = None
match obj.status:

View File

@ -88,14 +88,14 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
)
def test_api_version_no_auth(self):
response = self.client.get("/api/")
response = self.client.get("/api/documents/")
self.assertNotIn("X-Api-Version", response)
self.assertNotIn("X-Version", response)
def test_api_version_with_auth(self):
user = User.objects.create_superuser(username="test")
self.client.force_authenticate(user)
response = self.client.get("/api/")
response = self.client.get("/api/documents/")
self.assertIn("X-Api-Version", response)
self.assertIn("X-Version", response)

View 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)

View File

@ -48,10 +48,16 @@ from django.views.decorators.http import condition
from django.views.decorators.http import last_modified
from django.views.generic import TemplateView
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view
from drf_spectacular.utils import inline_serializer
from langdetect import detect
from packaging import version as packaging_version
from redis import Redis
from rest_framework import parsers
from rest_framework import serializers
from rest_framework.decorators import action
from rest_framework.exceptions import NotFound
from rest_framework.filters import OrderingFilter
@ -63,7 +69,6 @@ from rest_framework.mixins import RetrieveModelMixin
from rest_framework.mixins import UpdateModelMixin
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
from rest_framework.viewsets import ModelViewSet
from rest_framework.viewsets import ReadOnlyModelViewSet
@ -127,6 +132,7 @@ from documents.permissions import PaperlessObjectPermissions
from documents.permissions import get_objects_for_user_owner_aware
from documents.permissions import has_perms_owner_aware
from documents.permissions import set_permissions_for_object
from documents.schema import generate_object_with_permissions_schema
from documents.serialisers import AcknowledgeTasksViewSerializer
from documents.serialisers import BulkDownloadSerializer
from documents.serialisers import BulkEditObjectsSerializer
@ -256,6 +262,7 @@ class PermissionsAwareDocumentCountMixin(PassUserMixin):
)
@extend_schema_view(**generate_object_with_permissions_schema(CorrespondentSerializer))
class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
model = Correspondent
@ -292,6 +299,7 @@ class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
return super().retrieve(request, *args, **kwargs)
@extend_schema_view(**generate_object_with_permissions_schema(TagSerializer))
class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
model = Tag
@ -316,6 +324,7 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
ordering_fields = ("color", "name", "matching_algorithm", "match", "document_count")
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
model = DocumentType
@ -333,6 +342,177 @@ class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
ordering_fields = ("name", "matching_algorithm", "match", "document_count")
@extend_schema_view(
retrieve=extend_schema(
description="Retrieve a single document",
responses={
200: DocumentSerializer(all_fields=True),
400: None,
},
parameters=[
OpenApiParameter(
name="full_perms",
type=OpenApiTypes.BOOL,
location=OpenApiParameter.QUERY,
),
OpenApiParameter(
name="fields",
type=OpenApiTypes.STR,
many=True,
location=OpenApiParameter.QUERY,
),
],
),
download=extend_schema(
description="Download the document",
parameters=[
OpenApiParameter(
name="original",
type=OpenApiTypes.BOOL,
location=OpenApiParameter.QUERY,
),
],
responses={200: OpenApiTypes.BINARY},
),
history=extend_schema(
description="View the document history",
responses={
200: inline_serializer(
name="LogEntry",
many=True,
fields={
"id": serializers.IntegerField(),
"timestamp": serializers.DateTimeField(),
"action": serializers.CharField(),
"changes": serializers.DictField(),
"actor": inline_serializer(
name="Actor",
fields={
"id": serializers.IntegerField(),
"username": serializers.CharField(),
},
),
},
),
400: None,
403: None,
404: None,
},
),
metadata=extend_schema(
description="View the document metadata",
responses={
200: inline_serializer(
name="Metadata",
fields={
"original_checksum": serializers.CharField(),
"original_size": serializers.IntegerField(),
"original_mime_type": serializers.CharField(),
"media_filename": serializers.CharField(),
"has_archive_version": serializers.BooleanField(),
"original_metadata": serializers.DictField(),
"archive_checksum": serializers.CharField(),
"archive_media_filename": serializers.CharField(),
"original_filename": serializers.CharField(),
"archive_size": serializers.IntegerField(),
"archive_metadata": serializers.DictField(),
"lang": serializers.CharField(),
},
),
400: None,
403: None,
404: None,
},
),
notes=extend_schema(
description="View, add, or delete notes for the document",
responses={
200: {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"note": {"type": "string"},
"created": {"type": "string", "format": "date-time"},
"user": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"username": {"type": "string"},
"first_name": {"type": "string"},
"last_name": {"type": "string"},
},
},
},
},
},
400: None,
403: None,
404: None,
},
),
suggestions=extend_schema(
description="View suggestions for the document",
responses={
200: inline_serializer(
name="Suggestions",
fields={
"correspondents": serializers.ListField(
child=serializers.IntegerField(),
),
"tags": serializers.ListField(child=serializers.IntegerField()),
"document_types": serializers.ListField(
child=serializers.IntegerField(),
),
"storage_paths": serializers.ListField(
child=serializers.IntegerField(),
),
"dates": serializers.ListField(child=serializers.CharField()),
},
),
400: None,
403: None,
404: None,
},
),
thumb=extend_schema(
description="View the document thumbnail",
responses={200: OpenApiTypes.BINARY},
),
preview=extend_schema(
description="View the document preview",
responses={200: OpenApiTypes.BINARY},
),
share_links=extend_schema(
operation_id="document_share_links",
description="View share links for the document",
parameters=[
OpenApiParameter(
name="id",
type=OpenApiTypes.STR,
location=OpenApiParameter.PATH,
),
],
responses={
200: {
"type": "array",
"items": {
"type": "object",
"properties": {
"id": {"type": "integer"},
"created": {"type": "string", "format": "date-time"},
"expiration": {"type": "string", "format": "date-time"},
"slug": {"type": "string"},
},
},
},
400: None,
403: None,
404: None,
},
),
)
class DocumentViewSet(
PassUserMixin,
RetrieveModelMixin,
@ -466,7 +646,7 @@ class DocumentViewSet(
else:
return None
@action(methods=["get"], detail=True)
@action(methods=["get"], detail=True, filter_backends=[])
@method_decorator(cache_control(no_cache=True))
@method_decorator(
condition(etag_func=metadata_etag, last_modified_func=metadata_last_modified),
@ -525,7 +705,7 @@ class DocumentViewSet(
return Response(meta)
@action(methods=["get"], detail=True)
@action(methods=["get"], detail=True, filter_backends=[])
@method_decorator(cache_control(no_cache=True))
@method_decorator(
condition(
@ -576,7 +756,7 @@ class DocumentViewSet(
return Response(resp_data)
@action(methods=["get"], detail=True)
@action(methods=["get"], detail=True, filter_backends=[])
@method_decorator(cache_control(no_cache=True))
@method_decorator(
condition(etag_func=preview_etag, last_modified_func=preview_last_modified),
@ -588,7 +768,7 @@ class DocumentViewSet(
except (FileNotFoundError, Document.DoesNotExist):
raise Http404
@action(methods=["get"], detail=True)
@action(methods=["get"], detail=True, filter_backends=[])
@method_decorator(cache_control(no_cache=True))
@method_decorator(last_modified(thumbnail_last_modified))
def thumb(self, request, pk=None):
@ -647,6 +827,7 @@ class DocumentViewSet(
methods=["get", "post", "delete"],
detail=True,
permission_classes=[PaperlessNotePermissions],
filter_backends=[],
)
def notes(self, request, pk=None):
currentUser = request.user
@ -754,7 +935,7 @@ class DocumentViewSet(
},
)
@action(methods=["get"], detail=True)
@action(methods=["get"], detail=True, filter_backends=[])
def share_links(self, request, pk=None):
currentUser = request.user
try:
@ -772,21 +953,16 @@ class DocumentViewSet(
if request.method == "GET":
now = timezone.now()
links = [
{
"id": c.pk,
"created": c.created,
"expiration": c.expiration,
"slug": c.slug,
}
for c in ShareLink.objects.filter(document=doc)
links = (
ShareLink.objects.filter(document=doc)
.only("pk", "created", "expiration", "slug")
.exclude(expiration__lt=now)
.order_by("-created")
]
return Response(links)
)
serializer = ShareLinkSerializer(links, many=True)
return Response(serializer.data)
@action(methods=["get"], detail=True, name="Audit Trail")
@action(methods=["get"], detail=True, name="Audit Trail", filter_backends=[])
def history(self, request, pk=None):
if not settings.AUDIT_LOG_ENABLED:
return HttpResponseBadRequest("Audit log is disabled")
@ -848,6 +1024,26 @@ class DocumentViewSet(
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
@extend_schema_view(
list=extend_schema(
parameters=[
OpenApiParameter(
name="full_perms",
type=OpenApiTypes.BOOL,
location=OpenApiParameter.QUERY,
),
OpenApiParameter(
name="fields",
type=OpenApiTypes.STR,
many=True,
location=OpenApiParameter.QUERY,
),
],
responses={
200: DocumentSerializer(many=True, all_fields=True),
},
),
)
class UnifiedSearchViewSet(DocumentViewSet):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@ -915,6 +1111,33 @@ class UnifiedSearchViewSet(DocumentViewSet):
return Response(max_asn + 1)
@extend_schema_view(
list=extend_schema(
description="Logs view",
responses={
(200, "application/json"): serializers.ListSerializer(
child=serializers.CharField(),
),
},
),
retrieve=extend_schema(
description="Single log view",
operation_id="retrieve_log",
parameters=[
OpenApiParameter(
name="id",
type=OpenApiTypes.STR,
location=OpenApiParameter.PATH,
),
],
responses={
(200, "application/json"): serializers.ListSerializer(
child=serializers.CharField(),
),
(404, "application/json"): None,
},
),
)
class LogViewSet(ViewSet):
permission_classes = (IsAuthenticated, PaperlessAdminPermissions)
@ -923,11 +1146,12 @@ class LogViewSet(ViewSet):
def get_log_filename(self, log):
return os.path.join(settings.LOGGING_DIR, f"{log}.log")
def retrieve(self, request, pk=None, *args, **kwargs):
if pk not in self.log_files:
def retrieve(self, request, *args, **kwargs):
log_file = kwargs.get("pk")
if log_file not in self.log_files:
raise Http404
filename = self.get_log_filename(pk)
filename = self.get_log_filename(log_file)
if not os.path.isfile(filename):
raise Http404
@ -964,6 +1188,24 @@ class SavedViewViewSet(ModelViewSet, PassUserMixin):
serializer.save(owner=self.request.user)
@extend_schema_view(
post=extend_schema(
operation_id="bulk_edit",
description="Perform a bulk edit operation on a list of documents",
external_docs={
"description": "Further documentation",
"url": "https://docs.paperless-ngx.com/api/#bulk-editing",
},
responses={
200: inline_serializer(
name="BulkEditDocumentsResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class BulkEditView(PassUserMixin):
MODIFIED_FIELD_BY_METHOD = {
"set_correspondent": "correspondent",
@ -1113,6 +1355,18 @@ class BulkEditView(PassUserMixin):
)
@extend_schema_view(
post=extend_schema(
description="Upload a document via the API",
external_docs={
"description": "Further documentation",
"url": "https://docs.paperless-ngx.com/api/#file-uploads",
},
responses={
(200, "application/json"): OpenApiTypes.STR,
},
),
)
class PostDocumentView(GenericAPIView):
permission_classes = (IsAuthenticated,)
serializer_class = PostDocumentSerializer
@ -1169,6 +1423,63 @@ class PostDocumentView(GenericAPIView):
return Response(async_task.id)
@extend_schema_view(
post=extend_schema(
description="Get selection data for the selected documents",
responses={
(200, "application/json"): inline_serializer(
name="SelectionData",
fields={
"selected_correspondents": serializers.ListSerializer(
child=inline_serializer(
name="CorrespondentCounts",
fields={
"id": serializers.IntegerField(),
"document_count": serializers.IntegerField(),
},
),
),
"selected_tags": serializers.ListSerializer(
child=inline_serializer(
name="TagCounts",
fields={
"id": serializers.IntegerField(),
"document_count": serializers.IntegerField(),
},
),
),
"selected_document_types": serializers.ListSerializer(
child=inline_serializer(
name="DocumentTypeCounts",
fields={
"id": serializers.IntegerField(),
"document_count": serializers.IntegerField(),
},
),
),
"selected_storage_paths": serializers.ListSerializer(
child=inline_serializer(
name="StoragePathCounts",
fields={
"id": serializers.IntegerField(),
"document_count": serializers.IntegerField(),
},
),
),
"selected_custom_fields": serializers.ListSerializer(
child=inline_serializer(
name="CustomFieldCounts",
fields={
"id": serializers.IntegerField(),
"document_count": serializers.IntegerField(),
},
),
),
},
),
},
),
)
class SelectionDataView(GenericAPIView):
permission_classes = (IsAuthenticated,)
serializer_class = DocumentListSerializer
@ -1242,7 +1553,31 @@ class SelectionDataView(GenericAPIView):
return r
class SearchAutoCompleteView(APIView):
@extend_schema_view(
get=extend_schema(
description="Get a list of all available tags",
parameters=[
OpenApiParameter(
name="term",
required=False,
type=str,
description="Term to search for",
),
OpenApiParameter(
name="limit",
required=False,
type=int,
description="Number of completions to return",
),
],
responses={
(200, "application/json"): serializers.ListSerializer(
child=serializers.CharField(),
),
},
),
)
class SearchAutoCompleteView(GenericAPIView):
permission_classes = (IsAuthenticated,)
def get(self, request, format=None):
@ -1274,6 +1609,45 @@ class SearchAutoCompleteView(APIView):
)
@extend_schema_view(
get=extend_schema(
description="Global search",
parameters=[
OpenApiParameter(
name="query",
required=True,
type=str,
description="Query to search for",
),
OpenApiParameter(
name="db_only",
required=False,
type=bool,
description="Search only the database",
),
],
responses={
(200, "application/json"): inline_serializer(
name="SearchResult",
fields={
"total": serializers.IntegerField(),
"documents": DocumentSerializer(many=True),
"saved_views": SavedViewSerializer(many=True),
"tags": TagSerializer(many=True),
"correspondents": CorrespondentSerializer(many=True),
"document_types": DocumentTypeSerializer(many=True),
"storage_paths": StoragePathSerializer(many=True),
"users": UserSerializer(many=True),
"groups": GroupSerializer(many=True),
"mail_rules": MailRuleSerializer(many=True),
"mail_accounts": MailAccountSerializer(many=True),
"workflows": WorkflowSerializer(many=True),
"custom_fields": CustomFieldSerializer(many=True),
},
),
},
),
)
class GlobalSearchView(PassUserMixin):
permission_classes = (IsAuthenticated,)
serializer_class = SearchResultSerializer
@ -1469,7 +1843,15 @@ class GlobalSearchView(PassUserMixin):
)
class StatisticsView(APIView):
@extend_schema_view(
get=extend_schema(
description="Get statistics for the current user",
responses={
(200, "application/json"): OpenApiTypes.OBJECT,
},
),
)
class StatisticsView(GenericAPIView):
permission_classes = (IsAuthenticated,)
def get(self, request, format=None):
@ -1623,6 +2005,7 @@ class BulkDownloadView(GenericAPIView):
return response
@extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer))
class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
model = StoragePath
@ -1762,6 +2145,14 @@ class UiSettingsView(GenericAPIView):
)
@extend_schema_view(
get=extend_schema(
description="Get the current version of the Paperless-NGX server",
responses={
(200, "application/json"): OpenApiTypes.OBJECT,
},
),
)
class RemoteVersionView(GenericAPIView):
def get(self, request, format=None):
remote_version = "0.0.0"
@ -1802,6 +2193,33 @@ class RemoteVersionView(GenericAPIView):
)
@extend_schema_view(
acknowledge=extend_schema(
operation_id="acknowledge_tasks",
description="Acknowledge a list of tasks",
request={
"application/json": {
"type": "object",
"properties": {
"tasks": {
"type": "array",
"items": {"type": "integer"},
},
},
"required": ["tasks"],
},
},
responses={
(200, "application/json"): inline_serializer(
name="AcknowledgeTasks",
fields={
"result": serializers.IntegerField(),
},
),
(400, "application/json"): None,
},
),
)
class TasksViewSet(ReadOnlyModelViewSet):
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
serializer_class = TasksViewSerializer
@ -1907,6 +2325,24 @@ def serve_file(*, doc: Document, use_archive: bool, disposition: str):
return response
@extend_schema_view(
post=extend_schema(
operation_id="bulk_edit_objects",
description="Perform a bulk edit operation on a list of objects",
external_docs={
"description": "Further documentation",
"url": "https://docs.paperless-ngx.com/api/#objects",
},
responses={
200: inline_serializer(
name="BulkEditResult",
fields={
"result": serializers.CharField(),
},
),
},
),
)
class BulkEditObjectsView(PassUserMixin):
permission_classes = (IsAuthenticated,)
serializer_class = BulkEditObjectsSerializer
@ -2065,6 +2501,71 @@ class CustomFieldViewSet(ModelViewSet):
)
@extend_schema_view(
get=extend_schema(
description="Get the current system status of the Paperless-NGX server",
responses={
(200, "application/json"): inline_serializer(
name="SystemStatus",
fields={
"pngx_version": serializers.CharField(),
"server_os": serializers.CharField(),
"install_type": serializers.CharField(),
"storage": inline_serializer(
name="Storage",
fields={
"total": serializers.IntegerField(),
"available": serializers.IntegerField(),
},
),
"database": inline_serializer(
name="Database",
fields={
"type": serializers.CharField(),
"url": serializers.CharField(),
"status": serializers.CharField(),
"error": serializers.CharField(),
"migration_status": inline_serializer(
name="MigrationStatus",
fields={
"latest_migration": serializers.CharField(),
"unapplied_migrations": serializers.ListSerializer(
child=serializers.CharField(),
),
},
),
},
),
"tasks": inline_serializer(
name="Tasks",
fields={
"redis_url": serializers.CharField(),
"redis_status": serializers.CharField(),
"redis_error": serializers.CharField(),
"celery_status": serializers.CharField(),
},
),
"index": inline_serializer(
name="Index",
fields={
"status": serializers.CharField(),
"error": serializers.CharField(),
"last_modified": serializers.DateTimeField(),
},
),
"classifier": inline_serializer(
name="Classifier",
fields={
"status": serializers.CharField(),
"error": serializers.CharField(),
"last_trained": serializers.DateTimeField(),
},
),
},
),
},
),
)
class SystemStatusView(PassUserMixin):
permission_classes = (IsAuthenticated,)

View File

@ -11,22 +11,11 @@ from rest_framework import serializers
from rest_framework.authtoken.serializers import AuthTokenSerializer
from paperless.models import ApplicationConfiguration
from paperless_mail.serialisers import ObfuscatedPasswordField
logger = logging.getLogger("paperless.settings")
class ObfuscatedUserPasswordField(serializers.Field):
"""
Sends *** string instead of password in the clear
"""
def to_representation(self, value):
return "**********" if len(value) > 0 else ""
def to_internal_value(self, data):
return data
class PaperlessAuthTokenSerializer(AuthTokenSerializer):
code = serializers.CharField(
label="MFA Code",
@ -58,7 +47,7 @@ class PaperlessAuthTokenSerializer(AuthTokenSerializer):
class UserSerializer(serializers.ModelSerializer):
password = ObfuscatedUserPasswordField(required=False)
password = ObfuscatedPasswordField(required=False)
user_permissions = serializers.SlugRelatedField(
many=True,
queryset=Permission.objects.exclude(content_type__app_label="admin"),
@ -68,7 +57,7 @@ class UserSerializer(serializers.ModelSerializer):
inherited_permissions = serializers.SerializerMethodField()
is_mfa_enabled = serializers.SerializerMethodField()
def get_is_mfa_enabled(self, user: User):
def get_is_mfa_enabled(self, user: User) -> bool:
mfa_adapter = get_mfa_adapter()
return mfa_adapter.is_mfa_enabled(user)
@ -91,7 +80,7 @@ class UserSerializer(serializers.ModelSerializer):
"is_mfa_enabled",
)
def get_inherited_permissions(self, obj):
def get_inherited_permissions(self, obj) -> list[str]:
return obj.get_group_permissions()
def update(self, instance, validated_data):
@ -157,13 +146,13 @@ class SocialAccountSerializer(serializers.ModelSerializer):
"name",
)
def get_name(self, obj):
def get_name(self, obj) -> str:
return obj.get_provider_account().to_str()
class ProfileSerializer(serializers.ModelSerializer):
email = serializers.EmailField(allow_blank=True, required=False)
password = ObfuscatedUserPasswordField(required=False, allow_null=False)
password = ObfuscatedPasswordField(required=False, allow_null=False)
auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key")
social_accounts = SocialAccountSerializer(
many=True,
@ -171,11 +160,15 @@ class ProfileSerializer(serializers.ModelSerializer):
source="socialaccount_set",
)
is_mfa_enabled = serializers.SerializerMethodField()
has_usable_password = serializers.SerializerMethodField()
def get_is_mfa_enabled(self, user: User):
def get_is_mfa_enabled(self, user: User) -> bool:
mfa_adapter = get_mfa_adapter()
return mfa_adapter.is_mfa_enabled(user)
def get_has_usable_password(self, user: User) -> bool:
return user.has_usable_password()
class Meta:
model = User
fields = (

View File

@ -328,6 +328,8 @@ INSTALLED_APPS = [
"allauth.account",
"allauth.socialaccount",
"allauth.mfa",
"drf_spectacular",
"drf_spectacular_sidecar",
*env_apps,
]
@ -345,6 +347,25 @@ REST_FRAMEWORK = {
# Make sure these are ordered and that the most recent version appears
# last. See api.md#api-versioning when adding new versions.
"ALLOWED_VERSIONS": ["1", "2", "3", "4", "5", "6", "7"],
# DRF Spectacular default schema
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
# DRF Spectacular settings
SPECTACULAR_SETTINGS = {
"TITLE": "Paperless-ngx REST API",
"DESCRIPTION": "OpenAPI Spec for Paperless-ngx",
"VERSION": "6.0.0",
"SERVE_INCLUDE_SCHEMA": False,
"SWAGGER_UI_DIST": "SIDECAR",
"COMPONENT_SPLIT_REQUEST": True,
"EXTERNAL_DOCS": {
"description": "Paperless-ngx API Documentation",
"url": "https://docs.paperless-ngx.com/api/",
},
"ENUM_NAME_OVERRIDES": {
"MatchingAlgorithm": "documents.models.MatchingModel.MATCHING_ALGORITHMS",
},
}
if DEBUG:

View File

@ -14,6 +14,8 @@ from django.utils.translation import gettext_lazy as _
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic import RedirectView
from django.views.static import serve
from drf_spectacular.views import SpectacularAPIView
from drf_spectacular.views import SpectacularSwaggerView
from rest_framework.routers import DefaultRouter
from documents.views import BulkDownloadView
@ -203,6 +205,27 @@ urlpatterns = [
OauthCallbackView.as_view(),
name="oauth_callback",
),
re_path(
"^schema/",
include(
[
re_path(
"^$",
SpectacularAPIView.as_view(),
name="schema",
),
re_path(
"^view/",
SpectacularSwaggerView.as_view(),
name="swagger-ui",
),
],
),
),
re_path(
"^$", # Redirect to the API swagger view
RedirectView.as_view(url="schema/view/"),
),
*api_router.urls,
],
),

View File

@ -18,6 +18,9 @@ from django.http import HttpResponseForbidden
from django.http import HttpResponseNotFound
from django.views.generic import View
from django_filters.rest_framework import DjangoFilterBackend
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view
from rest_framework.authtoken.models import Token
from rest_framework.authtoken.views import ObtainAuthToken
from rest_framework.decorators import action
@ -27,7 +30,6 @@ from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import DjangoModelPermissions
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from documents.permissions import PaperlessObjectPermissions
@ -197,6 +199,34 @@ class ProfileView(GenericAPIView):
return Response(serializer.to_representation(user))
@extend_schema_view(
get=extend_schema(
responses={
(200, "application/json"): OpenApiTypes.OBJECT,
},
),
post=extend_schema(
request={
"application/json": {
"type": "object",
"properties": {
"secret": {"type": "string"},
"code": {"type": "string"},
},
"required": ["secret", "code"],
},
},
responses={
(200, "application/json"): OpenApiTypes.OBJECT,
},
),
delete=extend_schema(
responses={
(200, "application/json"): OpenApiTypes.BOOL,
404: OpenApiTypes.STR,
},
),
)
class TOTPView(GenericAPIView):
"""
TOTP views
@ -267,6 +297,16 @@ class TOTPView(GenericAPIView):
return HttpResponseNotFound("TOTP not found")
@extend_schema_view(
post=extend_schema(
request={
"application/json": None,
},
responses={
(200, "application/json"): OpenApiTypes.STR,
},
),
)
class GenerateAuthTokenView(GenericAPIView):
"""
Generates (or re-generates) an auth token, requires a logged in user
@ -287,6 +327,15 @@ class GenerateAuthTokenView(GenericAPIView):
)
@extend_schema_view(
list=extend_schema(
description="Get the application configuration",
external_docs={
"description": "Application Configuration",
"url": "https://docs.paperless-ngx.com/configuration/",
},
),
)
class ApplicationConfigurationViewSet(ModelViewSet):
model = ApplicationConfiguration
@ -296,6 +345,23 @@ class ApplicationConfigurationViewSet(ModelViewSet):
permission_classes = (IsAuthenticated, DjangoModelPermissions)
@extend_schema_view(
post=extend_schema(
request={
"application/json": {
"type": "object",
"properties": {
"id": {"type": "integer"},
},
"required": ["id"],
},
},
responses={
(200, "application/json"): OpenApiTypes.INT,
400: OpenApiTypes.STR,
},
),
)
class DisconnectSocialAccountView(GenericAPIView):
"""
Disconnects a social account provider from the user account
@ -315,7 +381,14 @@ class DisconnectSocialAccountView(GenericAPIView):
return HttpResponseBadRequest("Social account not found")
class SocialAccountProvidersView(APIView):
@extend_schema_view(
get=extend_schema(
responses={
(200, "application/json"): OpenApiTypes.OBJECT,
},
),
)
class SocialAccountProvidersView(GenericAPIView):
"""
List of social account providers
"""

View File

@ -8,13 +8,13 @@ from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
class ObfuscatedPasswordField(serializers.Field):
class ObfuscatedPasswordField(serializers.CharField):
"""
Sends *** string instead of password in the clear
"""
def to_representation(self, value):
return "*" * len(value)
def to_representation(self, value) -> str:
return "*" * max(10, len(value))
def to_internal_value(self, data):
return data

View File

@ -64,7 +64,7 @@ class TestAPIMailAccounts(DirectoriesMixin, APITestCase):
self.assertEqual(returned_account1["username"], account1.username)
self.assertEqual(
returned_account1["password"],
"*" * len(account1.password),
"**********",
)
self.assertEqual(returned_account1["imap_server"], account1.imap_server)
self.assertEqual(returned_account1["imap_port"], account1.imap_port)

View File

@ -5,7 +5,12 @@ from datetime import timedelta
from django.http import HttpResponseBadRequest
from django.http import HttpResponseRedirect
from django.utils import timezone
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema
from drf_spectacular.utils import extend_schema_view
from drf_spectacular.utils import inline_serializer
from httpx_oauth.oauth2 import GetAccessTokenError
from rest_framework import serializers
from rest_framework.decorators import action
from rest_framework.generics import GenericAPIView
from rest_framework.permissions import IsAuthenticated
@ -27,6 +32,19 @@ from paperless_mail.serialisers import MailRuleSerializer
from paperless_mail.tasks import process_mail_accounts
@extend_schema_view(
test=extend_schema(
operation_id="mail_account_test",
description="Test a mail account",
responses={
200: inline_serializer(
name="MailAccountTestResponse",
fields={"success": serializers.BooleanField()},
),
400: OpenApiTypes.STR,
},
),
)
class MailAccountViewSet(ModelViewSet, PassUserMixin):
model = MailAccount
@ -106,6 +124,12 @@ class MailRuleViewSet(ModelViewSet, PassUserMixin):
filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
@extend_schema_view(
get=extend_schema(
description="Callback view for OAuth2 authentication",
responses={200: None},
),
)
class OauthCallbackView(GenericAPIView):
permission_classes = (IsAuthenticated,)