django-soft-delete = "*"
djangorestframework = "~=3.15.2"
djangorestframework-guardian = "*"
+drf-spectacular = "*"
+drf-spectacular-sidecar = "*"
drf-writable-nested = "*"
bleach = "*"
celery = {extras = ["redis"], version = "*"}
{
"_meta": {
"hash": {
- "sha256": "6a7869231917d0cf6f5852520b5cb9b0df3802ed162b1a8107d0b1e1c37f0535"
+ "sha256": "9fdd406708b9c0693041c0506a29b7ab83ce196460ee299bfc764f1e03603e1a"
},
"pipfile-spec": 6,
"requires": {},
"markers": "python_version >= '3.8'",
"version": "==5.0.1"
},
+ "attrs": {
+ "hashes": [
+ "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346",
+ "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"
+ ],
+ "markers": "python_version >= '3.7'",
+ "version": "==24.2.0"
+ },
"billiard": {
"hashes": [
"sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f",
"index": "pypi",
"version": "==0.3.0"
},
+ "drf-spectacular": {
+ "hashes": [
+ "sha256:2c778a47a40ab2f5078a7c42e82baba07397bb35b074ae4680721b2805943061",
+ "sha256:856e7edf1056e49a4245e87a61e8da4baff46c83dbc25be1da2df77f354c7cb4"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.7'",
+ "version": "==0.28.0"
+ },
+ "drf-spectacular-sidecar": {
+ "hashes": [
+ "sha256:e2efd49c5bd1a607fd5d120d9da58d78e587852db8220b8880282a849296ff83",
+ "sha256:fcfccc72cbdbe41e93f8416fa0c712d14126b8d1629e65c09c07c8edea24aad0"
+ ],
+ "index": "pypi",
+ "markers": "python_version >= '3.6'",
+ "version": "==2024.11.1"
+ },
"drf-writable-nested": {
"hashes": [
"sha256:d8ddc606dc349e56373810842965712a5789e6a5ca7704729d15429b95f8f2ee"
],
"version": "==0.5.1"
},
+ "inflection": {
+ "hashes": [
+ "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417",
+ "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"
+ ],
+ "markers": "python_version >= '3.5'",
+ "version": "==0.5.1"
+ },
"inotify-simple": {
"hashes": [
"sha256:8440ffe49c4ae81a8df57c1ae1eb4b6bfa7acb830099bfb3e305b383005cc128"
"markers": "python_version >= '3.8'",
"version": "==1.4.2"
},
+ "jsonschema": {
+ "hashes": [
+ "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4",
+ "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"
+ ],
+ "markers": "python_version >= '3.8'",
+ "version": "==4.23.0"
+ },
+ "jsonschema-specifications": {
+ "hashes": [
+ "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272",
+ "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf"
+ ],
+ "markers": "python_version >= '3.9'",
+ "version": "==2024.10.1"
+ },
"kombu": {
"hashes": [
"sha256:14212f5ccf022fc0a70453bb025a1dcc32782a588c49ea866884047d66e14763",
"markers": "python_version >= '3.8'",
"version": "==5.2.1"
},
+ "referencing": {
+ "hashes": [
+ "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c",
+ "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"
+ ],
+ "markers": "python_version >= '3.8'",
+ "version": "==0.35.1"
+ },
"regex": {
"hashes": [
"sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c",
"markers": "python_full_version >= '3.8.0'",
"version": "==13.9.4"
},
+ "rpds-py": {
+ "hashes": [
+ "sha256:031819f906bb146561af051c7cef4ba2003d28cff07efacef59da973ff7969ba",
+ "sha256:0626238a43152918f9e72ede9a3b6ccc9e299adc8ade0d67c5e142d564c9a83d",
+ "sha256:085ed25baac88953d4283e5b5bd094b155075bb40d07c29c4f073e10623f9f2e",
+ "sha256:0a9e0759e7be10109645a9fddaaad0619d58c9bf30a3f248a2ea57a7c417173a",
+ "sha256:0c025820b78817db6a76413fff6866790786c38f95ea3f3d3c93dbb73b632202",
+ "sha256:1ff2eba7f6c0cb523d7e9cff0903f2fe1feff8f0b2ceb6bd71c0e20a4dcee271",
+ "sha256:20cc1ed0bcc86d8e1a7e968cce15be45178fd16e2ff656a243145e0b439bd250",
+ "sha256:241e6c125568493f553c3d0fdbb38c74babf54b45cef86439d4cd97ff8feb34d",
+ "sha256:2c51d99c30091f72a3c5d126fad26236c3f75716b8b5e5cf8effb18889ced928",
+ "sha256:2d6129137f43f7fa02d41542ffff4871d4aefa724a5fe38e2c31a4e0fd343fb0",
+ "sha256:30b912c965b2aa76ba5168fd610087bad7fcde47f0a8367ee8f1876086ee6d1d",
+ "sha256:30bdc973f10d28e0337f71d202ff29345320f8bc49a31c90e6c257e1ccef4333",
+ "sha256:320c808df533695326610a1b6a0a6e98f033e49de55d7dc36a13c8a30cfa756e",
+ "sha256:32eb88c30b6a4f0605508023b7141d043a79b14acb3b969aa0b4f99b25bc7d4a",
+ "sha256:3b766a9f57663396e4f34f5140b3595b233a7b146e94777b97a8413a1da1be18",
+ "sha256:3b929c2bb6e29ab31f12a1117c39f7e6d6450419ab7464a4ea9b0b417174f044",
+ "sha256:3e30a69a706e8ea20444b98a49f386c17b26f860aa9245329bab0851ed100677",
+ "sha256:3e53861b29a13d5b70116ea4230b5f0f3547b2c222c5daa090eb7c9c82d7f664",
+ "sha256:40c91c6e34cf016fa8e6b59d75e3dbe354830777fcfd74c58b279dceb7975b75",
+ "sha256:4991ca61656e3160cdaca4851151fd3f4a92e9eba5c7a530ab030d6aee96ec89",
+ "sha256:4ab2c2a26d2f69cdf833174f4d9d86118edc781ad9a8fa13970b527bf8236027",
+ "sha256:4e8921a259f54bfbc755c5bbd60c82bb2339ae0324163f32868f63f0ebb873d9",
+ "sha256:4eb2de8a147ffe0626bfdc275fc6563aa7bf4b6db59cf0d44f0ccd6ca625a24e",
+ "sha256:5145282a7cd2ac16ea0dc46b82167754d5e103a05614b724457cffe614f25bd8",
+ "sha256:520ed8b99b0bf86a176271f6fe23024323862ac674b1ce5b02a72bfeff3fff44",
+ "sha256:52c041802a6efa625ea18027a0723676a778869481d16803481ef6cc02ea8cb3",
+ "sha256:5555db3e618a77034954b9dc547eae94166391a98eb867905ec8fcbce1308d95",
+ "sha256:58a0e345be4b18e6b8501d3b0aa540dad90caeed814c515e5206bb2ec26736fd",
+ "sha256:590ef88db231c9c1eece44dcfefd7515d8bf0d986d64d0caf06a81998a9e8cab",
+ "sha256:5afb5efde74c54724e1a01118c6e5c15e54e642c42a1ba588ab1f03544ac8c7a",
+ "sha256:688c93b77e468d72579351a84b95f976bd7b3e84aa6686be6497045ba84be560",
+ "sha256:6b4ef7725386dc0762857097f6b7266a6cdd62bfd209664da6712cb26acef035",
+ "sha256:6bc0e697d4d79ab1aacbf20ee5f0df80359ecf55db33ff41481cf3e24f206919",
+ "sha256:6dcc4949be728ede49e6244eabd04064336012b37f5c2200e8ec8eb2988b209c",
+ "sha256:6f54e7106f0001244a5f4cf810ba8d3f9c542e2730821b16e969d6887b664266",
+ "sha256:808f1ac7cf3b44f81c9475475ceb221f982ef548e44e024ad5f9e7060649540e",
+ "sha256:8404b3717da03cbf773a1d275d01fec84ea007754ed380f63dfc24fb76ce4592",
+ "sha256:878f6fea96621fda5303a2867887686d7a198d9e0f8a40be100a63f5d60c88c9",
+ "sha256:8a7ff941004d74d55a47f916afc38494bd1cfd4b53c482b77c03147c91ac0ac3",
+ "sha256:95a5bad1ac8a5c77b4e658671642e4af3707f095d2b78a1fdd08af0dfb647624",
+ "sha256:97ef67d9bbc3e15584c2f3c74bcf064af36336c10d2e21a2131e123ce0f924c9",
+ "sha256:98486337f7b4f3c324ab402e83453e25bb844f44418c066623db88e4c56b7c7b",
+ "sha256:98e4fe5db40db87ce1c65031463a760ec7906ab230ad2249b4572c2fc3ef1f9f",
+ "sha256:998a8080c4495e4f72132f3d66ff91f5997d799e86cec6ee05342f8f3cda7dca",
+ "sha256:9afe42102b40007f588666bc7de82451e10c6788f6f70984629db193849dced1",
+ "sha256:9e20da3957bdf7824afdd4b6eeb29510e83e026473e04952dca565170cd1ecc8",
+ "sha256:a017f813f24b9df929674d0332a374d40d7f0162b326562daae8066b502d0590",
+ "sha256:a429b99337062877d7875e4ff1a51fe788424d522bd64a8c0a20ef3021fdb6ed",
+ "sha256:a58ce66847711c4aa2ecfcfaff04cb0327f907fead8945ffc47d9407f41ff952",
+ "sha256:a78d8b634c9df7f8d175451cfeac3810a702ccb85f98ec95797fa98b942cea11",
+ "sha256:a89a8ce9e4e75aeb7fa5d8ad0f3fecdee813802592f4f46a15754dcb2fd6b061",
+ "sha256:a8eeec67590e94189f434c6d11c426892e396ae59e4801d17a93ac96b8c02a6c",
+ "sha256:aaeb25ccfb9b9014a10eaf70904ebf3f79faaa8e60e99e19eef9f478651b9b74",
+ "sha256:ad116dda078d0bc4886cb7840e19811562acdc7a8e296ea6ec37e70326c1b41c",
+ "sha256:af04ac89c738e0f0f1b913918024c3eab6e3ace989518ea838807177d38a2e94",
+ "sha256:af4a644bf890f56e41e74be7d34e9511e4954894d544ec6b8efe1e21a1a8da6c",
+ "sha256:b21747f79f360e790525e6f6438c7569ddbfb1b3197b9e65043f25c3c9b489d8",
+ "sha256:b229ce052ddf1a01c67d68166c19cb004fb3612424921b81c46e7ea7ccf7c3bf",
+ "sha256:b4de1da871b5c0fd5537b26a6fc6814c3cc05cabe0c941db6e9044ffbb12f04a",
+ "sha256:b80b4690bbff51a034bfde9c9f6bf9357f0a8c61f548942b80f7b66356508bf5",
+ "sha256:b876f2bc27ab5954e2fd88890c071bd0ed18b9c50f6ec3de3c50a5ece612f7a6",
+ "sha256:b8f107395f2f1d151181880b69a2869c69e87ec079c49c0016ab96860b6acbe5",
+ "sha256:b9b76e2afd585803c53c5b29e992ecd183f68285b62fe2668383a18e74abe7a3",
+ "sha256:c2b2f71c6ad6c2e4fc9ed9401080badd1469fa9889657ec3abea42a3d6b2e1ed",
+ "sha256:c3761f62fcfccf0864cc4665b6e7c3f0c626f0380b41b8bd1ce322103fa3ef87",
+ "sha256:c38dbf31c57032667dd5a2f0568ccde66e868e8f78d5a0d27dcc56d70f3fcd3b",
+ "sha256:ca9989d5d9b1b300bc18e1801c67b9f6d2c66b8fd9621b36072ed1df2c977f72",
+ "sha256:cbd7504a10b0955ea287114f003b7ad62330c9e65ba012c6223dba646f6ffd05",
+ "sha256:d167e4dbbdac48bd58893c7e446684ad5d425b407f9336e04ab52e8b9194e2ed",
+ "sha256:d2132377f9deef0c4db89e65e8bb28644ff75a18df5293e132a8d67748397b9f",
+ "sha256:da52d62a96e61c1c444f3998c434e8b263c384f6d68aca8274d2e08d1906325c",
+ "sha256:daa8efac2a1273eed2354397a51216ae1e198ecbce9036fba4e7610b308b6153",
+ "sha256:dc5695c321e518d9f03b7ea6abb5ea3af4567766f9852ad1560f501b17588c7b",
+ "sha256:de552f4a1916e520f2703ec474d2b4d3f86d41f353e7680b597512ffe7eac5d0",
+ "sha256:de609a6f1b682f70bb7163da745ee815d8f230d97276db049ab447767466a09d",
+ "sha256:e12bb09678f38b7597b8346983d2323a6482dcd59e423d9448108c1be37cac9d",
+ "sha256:e168afe6bf6ab7ab46c8c375606298784ecbe3ba31c0980b7dcbb9631dcba97e",
+ "sha256:e78868e98f34f34a88e23ee9ccaeeec460e4eaf6db16d51d7a9b883e5e785a5e",
+ "sha256:e860f065cc4ea6f256d6f411aba4b1251255366e48e972f8a347cf88077b24fd",
+ "sha256:ea3a6ac4d74820c98fcc9da4a57847ad2cc36475a8bd9683f32ab6d47a2bd682",
+ "sha256:ebf64e281a06c904a7636781d2e973d1f0926a5b8b480ac658dc0f556e7779f4",
+ "sha256:ed6378c9d66d0de903763e7706383d60c33829581f0adff47b6535f1802fa6db",
+ "sha256:ee1e4fc267b437bb89990b2f2abf6c25765b89b72dd4a11e21934df449e0c976",
+ "sha256:ee4eafd77cc98d355a0d02f263efc0d3ae3ce4a7c24740010a8b4012bbb24937",
+ "sha256:efec946f331349dfc4ae9d0e034c263ddde19414fe5128580f512619abed05f1",
+ "sha256:f414da5c51bf350e4b7960644617c130140423882305f7574b6cf65a3081cecb",
+ "sha256:f71009b0d5e94c0e86533c0b27ed7cacc1239cb51c178fd239c3cfefefb0400a",
+ "sha256:f983e4c2f603c95dde63df633eec42955508eefd8d0f0e6d236d31a044c882d7",
+ "sha256:faa5e8496c530f9c71f2b4e1c49758b06e5f4055e17144906245c99fa6d45356",
+ "sha256:fed5dfefdf384d6fe975cc026886aece4f292feaf69d0eeb716cfd3c5a4dd8be"
+ ],
+ "markers": "python_version >= '3.9'",
+ "version": "==0.21.0"
+ },
"scikit-learn": {
"hashes": [
"sha256:0650e730afb87402baa88afbf31c07b84c98272622aaba002559b614600ca691",
"markers": "python_version >= '3.8'",
"version": "==5.2"
},
+ "uritemplate": {
+ "hashes": [
+ "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0",
+ "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"
+ ],
+ "markers": "python_version >= '3.6'",
+ "version": "==4.1.1"
+ },
"urllib3": {
"hashes": [
"sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df",
# The REST API
-Paperless makes use of the [Django REST
-Framework](https://django-rest-framework.org/) standard API interface. It
-provides a browsable API for most of its endpoints, which you can
-inspect at `http://<paperless-host>:<port>/api/`. This also documents
-most of the available filters and ordering fields.
-
-The API provides the following main endpoints:
-
-- `/api/correspondents/`: Full CRUD support.
-- `/api/custom_fields/`: Full CRUD support.
-- `/api/documents/`: Full CRUD support, except POSTing new documents.
- See [below](#file-uploads).
-- `/api/document_types/`: Full CRUD support.
-- `/api/groups/`: Full CRUD support.
-- `/api/logs/`: Read-Only.
-- `/api/mail_accounts/`: Full CRUD support.
-- `/api/mail_rules/`: Full CRUD support.
-- `/api/profile/`: GET, PATCH
-- `/api/share_links/`: Full CRUD support.
-- `/api/storage_paths/`: Full CRUD support.
-- `/api/tags/`: Full CRUD support.
-- `/api/tasks/`: Read-only.
-- `/api/users/`: Full CRUD support.
-- `/api/workflows/`: Full CRUD support.
-- `/api/search/` GET, see [below](#global-search).
-
-All of these endpoints except for the logging endpoint allow you to
-fetch (and edit and delete where appropriate) individual objects by
-appending their primary key to the path, e.g. `/api/documents/454/`.
-
-The objects served by the document endpoint contain the following
-fields:
-
-- `id`: ID of the document. Read-only.
-- `title`: Title of the document.
-- `content`: Plain text content of the document.
-- `tags`: List of IDs of tags assigned to this document, or empty
- list.
-- `document_type`: Document type of this document, or null.
-- `correspondent`: Correspondent of this document or null.
-- `created`: The date time at which this document was created.
-- `created_date`: The date (YYYY-MM-DD) at which this document was
- created. Optional. If also passed with created, this is ignored.
-- `modified`: The date at which this document was last edited in
- paperless. Read-only.
-- `added`: The date at which this document was added to paperless.
- Read-only.
-- `archive_serial_number`: The identifier of this document in a
- physical document archive.
-- `original_file_name`: Verbose filename of the original document.
- Read-only.
-- `archived_file_name`: Verbose filename of the archived document.
- Read-only. Null if no archived document is available.
-- `notes`: Array of notes associated with the document.
-- `page_count`: Number of pages.
-- `set_permissions`: Allows setting document permissions. Optional,
- write-only. See [below](#permissions).
-- `custom_fields`: Array of custom fields & values, specified as
- `{ field: CUSTOM_FIELD_ID, value: VALUE }`
+Paperless-ngx now ships with a fully-documented REST API and a browsable
+web interface to explore it. The API browsable interface is available at
+`/api/schema/view/`.
-!!! note
-
- Note that all endpoint URLs must end with a `/`slash.
-
-## Downloading documents
-
-In addition to that, the document endpoint offers these additional
-actions on individual documents:
-
-- `/api/documents/<pk>/download/`: Download the document.
-- `/api/documents/<pk>/preview/`: Display the document inline, without
- downloading it.
-- `/api/documents/<pk>/thumb/`: Download the PNG thumbnail of a
- document.
-
-Paperless generates archived PDF/A documents from consumed files and
-stores both the original files as well as the archived files. By
-default, the endpoints for previews and downloads serve the archived
-file, if it is available. Otherwise, the original file is served. Some
-document cannot be archived.
-
-The endpoints correctly serve the response header fields
-`Content-Disposition` and `Content-Type` to indicate the filename for
-download and the type of content of the document.
-
-In order to download or preview the original document when an archived
-document is available, supply the query parameter `original=true`.
-
-!!! tip
-
- Paperless used to provide these functionality at `/fetch/<pk>/preview`,
- `/fetch/<pk>/thumb` and `/fetch/<pk>/doc`. Redirects to the new URLs are
- in place. However, if you use these old URLs to access documents, you
- should update your app or script to use the new URLs.
-
-## Getting document metadata
-
-The api also has an endpoint to retrieve read-only metadata about
-specific documents. this information is not served along with the
-document objects, since it requires reading files and would therefore
-slow down document lists considerably.
-
-Access the metadata of a document with an ID `id` at
-`/api/documents/<id>/metadata/`.
-
-The endpoint reports the following data:
-
-- `original_checksum`: MD5 checksum of the original document.
-- `original_size`: Size of the original document, in bytes.
-- `original_mime_type`: Mime type of the original document.
-- `media_filename`: Current filename of the document, under which it
- is stored inside the media directory.
-- `has_archive_version`: True, if this document is archived, false
- otherwise.
-- `original_metadata`: A list of metadata associated with the original
- document. See below.
-- `archive_checksum`: MD5 checksum of the archived document, or null.
-- `archive_size`: Size of the archived document in bytes, or null.
-- `archive_metadata`: Metadata associated with the archived document,
- or null. See below.
-
-File metadata is reported as a list of objects in the following form:
-
-```json
-[
- {
- "namespace": "http://ns.adobe.com/pdf/1.3/",
- "prefix": "pdf",
- "key": "Producer",
- "value": "SparklePDF, Fancy edition"
- }
-]
-```
-
-`namespace` and `prefix` can be null. The actual metadata reported
-depends on the file type and the metadata available in that specific
-document. Paperless only reports PDF metadata at this point.
-
-## Documents additional endpoints
-
-- `/api/documents/<id>/notes/`: Retrieve notes for a document.
-- `/api/documents/<id>/share_links/`: Retrieve share links for a document.
-- `/api/documents/<id>/history/`: Retrieve history of changes for a document.
+Further documentation is provided here for some endpoints and features.
## Authorization
[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
- `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
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.Basic
],
+ ...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
+ CustomFieldQueryOperatorGroups.Exact
+ ],
...CUSTOM_FIELD_QUERY_OPERATORS_BY_GROUP[
CustomFieldQueryOperatorGroups.String
],
export enum CustomFieldQueryOperatorGroups {
Basic = 'basic',
+ Exact = 'exact',
String = 'string',
Arithmetic = 'arithmetic',
Containment = 'containment',
[CustomFieldQueryOperatorGroups.Basic]: [
CustomFieldQueryOperator.Exists,
CustomFieldQueryOperator.IsNull,
- CustomFieldQueryOperator.Exact,
],
+ [CustomFieldQueryOperatorGroups.Exact]: [CustomFieldQueryOperator.Exact],
[CustomFieldQueryOperatorGroups.String]: [CustomFieldQueryOperator.IContains],
[CustomFieldQueryOperatorGroups.Arithmetic]: [
CustomFieldQueryOperator.GreaterThan,
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,
],
],
[CustomFieldDataType.Select]: [
CustomFieldQueryOperatorGroups.Basic,
+ CustomFieldQueryOperatorGroups.Exact,
CustomFieldQueryOperatorGroups.Subset,
],
}
document_consumption_finished.connect(run_workflows_added)
document_updated.connect(run_workflows_updated)
+ import documents.schema # noqa: F401
+
AppConfig.ready(self)
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
return qs
+@extend_schema_field(serializers.BooleanField)
class InboxFilter(Filter):
def filter(self, qs, value):
if value == "true":
return qs
+@extend_schema_field(serializers.CharField)
class TitleContentFilter(Filter):
def filter(self, qs, value):
if value:
return qs
+@extend_schema_field(serializers.BooleanField)
class SharedByUser(Filter):
def filter(self, qs, value):
ctype = ContentType.objects.get_for_model(self.model)
}
+@extend_schema_field(serializers.CharField)
class CustomFieldsFilter(Filter):
def filter(self, qs, value):
if value:
self._current_depth -= 1
+@extend_schema_field(serializers.CharField)
class CustomFieldQueryFilter(Filter):
def __init__(self, validation_prefix):
"""
--- /dev/null
+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"]
+ }
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
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()
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,
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()}"
},
}
- 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
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.
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,
)
-class ColorField(serializers.Field):
+class DeprecatedColors:
COLOURS = (
(1, "#a6cee3"),
(2, "#1f78b4"),
(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
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))
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)
)
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):
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:
# 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"
):
class Meta:
model = Document
- depth = 1
fields = (
"id",
"correspondent",
class TasksViewSerializer(OwnedObjectSerializer):
class Meta:
model = PaperlessTask
- depth = 1
fields = (
"id",
"task_id",
type = serializers.SerializerMethodField()
- def get_type(self, obj):
+ def get_type(self, obj) -> str:
# just file tasks, for now
return "file"
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:
)
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)
--- /dev/null
+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)
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
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
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
)
+@extend_schema_view(**generate_object_with_permissions_schema(CorrespondentSerializer))
class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
model = Correspondent
return super().retrieve(request, *args, **kwargs)
+@extend_schema_view(**generate_object_with_permissions_schema(TagSerializer))
class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
model = Tag
ordering_fields = ("color", "name", "matching_algorithm", "match", "document_count")
+@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
model = DocumentType
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,
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),
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(
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),
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):
methods=["get", "post", "delete"],
detail=True,
permission_classes=[PaperlessNotePermissions],
+ filter_backends=[],
)
def notes(self, request, pk=None):
currentUser = request.user
},
)
- @action(methods=["get"], detail=True)
+ @action(methods=["get"], detail=True, filter_backends=[])
def share_links(self, request, pk=None):
currentUser = request.user
try:
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")
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)
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)
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
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",
)
+@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
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
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):
)
+@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
)
-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):
return response
+@extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer))
class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
model = StoragePath
)
+@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"
)
+@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
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
)
+@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,)
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",
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"),
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)
"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):
"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,
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 = (
"allauth.account",
"allauth.socialaccount",
"allauth.mfa",
+ "drf_spectacular",
+ "drf_spectacular_sidecar",
*env_apps,
]
# 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:
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
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,
],
),
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
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
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
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
)
+@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
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
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
"""
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
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)
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
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
filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
+@extend_schema_view(
+ get=extend_schema(
+ description="Callback view for OAuth2 authentication",
+ responses={200: None},
+ ),
+)
class OauthCallbackView(GenericAPIView):
permission_classes = (IsAuthenticated,)