mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Feature: openapi spec, full api browser (#8948)
This commit is contained in:
@@ -28,4 +28,6 @@ class DocumentsConfig(AppConfig):
|
||||
document_consumption_finished.connect(run_workflows_added)
|
||||
document_updated.connect(run_workflows_updated)
|
||||
|
||||
import documents.schema # noqa: F401
|
||||
|
||||
AppConfig.ready(self)
|
||||
|
@@ -22,6 +22,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django_filters.rest_framework import BooleanFilter
|
||||
from django_filters.rest_framework import Filter
|
||||
from django_filters.rest_framework import FilterSet
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from guardian.utils import get_group_obj_perms_model
|
||||
from guardian.utils import get_user_obj_perms_model
|
||||
from rest_framework import serializers
|
||||
@@ -124,6 +125,7 @@ class ObjectFilter(Filter):
|
||||
return qs
|
||||
|
||||
|
||||
@extend_schema_field(serializers.BooleanField)
|
||||
class InboxFilter(Filter):
|
||||
def filter(self, qs, value):
|
||||
if value == "true":
|
||||
@@ -134,6 +136,7 @@ class InboxFilter(Filter):
|
||||
return qs
|
||||
|
||||
|
||||
@extend_schema_field(serializers.CharField)
|
||||
class TitleContentFilter(Filter):
|
||||
def filter(self, qs, value):
|
||||
if value:
|
||||
@@ -142,6 +145,7 @@ class TitleContentFilter(Filter):
|
||||
return qs
|
||||
|
||||
|
||||
@extend_schema_field(serializers.BooleanField)
|
||||
class SharedByUser(Filter):
|
||||
def filter(self, qs, value):
|
||||
ctype = ContentType.objects.get_for_model(self.model)
|
||||
@@ -186,6 +190,7 @@ class CustomFieldFilterSet(FilterSet):
|
||||
}
|
||||
|
||||
|
||||
@extend_schema_field(serializers.CharField)
|
||||
class CustomFieldsFilter(Filter):
|
||||
def filter(self, qs, value):
|
||||
if value:
|
||||
@@ -642,6 +647,7 @@ class CustomFieldQueryParser:
|
||||
self._current_depth -= 1
|
||||
|
||||
|
||||
@extend_schema_field(serializers.CharField)
|
||||
class CustomFieldQueryFilter(Filter):
|
||||
def __init__(self, validation_prefix):
|
||||
"""
|
||||
|
44
src/documents/schema.py
Normal file
44
src/documents/schema.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from drf_spectacular.extensions import OpenApiAuthenticationExtension
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter
|
||||
from drf_spectacular.utils import extend_schema
|
||||
|
||||
|
||||
class AngularApiAuthenticationOverrideScheme(OpenApiAuthenticationExtension):
|
||||
target_class = "paperless.auth.AngularApiAuthenticationOverride"
|
||||
name = "AngularApiAuthenticationOverride"
|
||||
|
||||
def get_security_definition(self, auto_schema): # pragma: no cover
|
||||
return {
|
||||
"type": "http",
|
||||
"scheme": "basic",
|
||||
}
|
||||
|
||||
|
||||
class PaperelessBasicAuthenticationScheme(OpenApiAuthenticationExtension):
|
||||
target_class = "paperless.auth.PaperlessBasicAuthentication"
|
||||
name = "PaperelessBasicAuthentication"
|
||||
|
||||
def get_security_definition(self, auto_schema):
|
||||
return {
|
||||
"type": "http",
|
||||
"scheme": "basic",
|
||||
}
|
||||
|
||||
|
||||
def generate_object_with_permissions_schema(serializer_class):
|
||||
return {
|
||||
operation: extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="full_perms",
|
||||
type=OpenApiTypes.BOOL,
|
||||
location=OpenApiParameter.QUERY,
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: serializer_class(many=operation == "list", all_fields=True),
|
||||
},
|
||||
)
|
||||
for operation in ["list", "retrieve"]
|
||||
}
|
@@ -19,6 +19,7 @@ from django.core.validators import integer_validator
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from drf_writable_nested.serializers import NestedUpdateMixin
|
||||
from guardian.core import ObjectPermissionChecker
|
||||
from guardian.shortcuts import get_users_with_perms
|
||||
@@ -86,7 +87,7 @@ class DynamicFieldsModelSerializer(serializers.ModelSerializer):
|
||||
class MatchingModelSerializer(serializers.ModelSerializer):
|
||||
document_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
def get_slug(self, obj):
|
||||
def get_slug(self, obj) -> str:
|
||||
return slugify(obj.name)
|
||||
|
||||
slug = SerializerMethodField()
|
||||
@@ -179,9 +180,47 @@ class SerializerWithPerms(serializers.Serializer):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.user = kwargs.pop("user", None)
|
||||
self.full_perms = kwargs.pop("full_perms", False)
|
||||
self.all_fields = kwargs.pop("all_fields", False)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
@extend_schema_field(
|
||||
field={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"view": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"change": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
class SetPermissionsSerializer(serializers.DictField):
|
||||
pass
|
||||
|
||||
|
||||
class OwnedObjectSerializer(
|
||||
SerializerWithPerms,
|
||||
serializers.ModelSerializer,
|
||||
@@ -190,16 +229,50 @@ class OwnedObjectSerializer(
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
try:
|
||||
if self.full_perms:
|
||||
self.fields.pop("user_can_change")
|
||||
self.fields.pop("is_shared_by_requester")
|
||||
else:
|
||||
self.fields.pop("permissions")
|
||||
except KeyError:
|
||||
pass
|
||||
if not self.all_fields:
|
||||
try:
|
||||
if self.full_perms:
|
||||
self.fields.pop("user_can_change")
|
||||
self.fields.pop("is_shared_by_requester")
|
||||
else:
|
||||
self.fields.pop("permissions")
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def get_permissions(self, obj):
|
||||
@extend_schema_field(
|
||||
field={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"view": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"change": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"users": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
"groups": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
def get_permissions(self, obj) -> dict:
|
||||
view_codename = f"view_{obj.__class__.__name__.lower()}"
|
||||
change_codename = f"change_{obj.__class__.__name__.lower()}"
|
||||
|
||||
@@ -228,7 +301,7 @@ class OwnedObjectSerializer(
|
||||
},
|
||||
}
|
||||
|
||||
def get_user_can_change(self, obj):
|
||||
def get_user_can_change(self, obj) -> bool:
|
||||
checker = ObjectPermissionChecker(self.user) if self.user is not None else None
|
||||
return (
|
||||
obj.owner is None
|
||||
@@ -271,7 +344,7 @@ class OwnedObjectSerializer(
|
||||
|
||||
return set(user_permission_pks) | set(group_permission_pks)
|
||||
|
||||
def get_is_shared_by_requester(self, obj: Document):
|
||||
def get_is_shared_by_requester(self, obj: Document) -> bool:
|
||||
# First check the context to see if `shared_object_pks` is set by the parent.
|
||||
shared_object_pks = self.context.get("shared_object_pks")
|
||||
# If not just check if the current object is shared.
|
||||
@@ -283,7 +356,7 @@ class OwnedObjectSerializer(
|
||||
user_can_change = SerializerMethodField(read_only=True)
|
||||
is_shared_by_requester = SerializerMethodField(read_only=True)
|
||||
|
||||
set_permissions = serializers.DictField(
|
||||
set_permissions = SetPermissionsSerializer(
|
||||
label="Set permissions",
|
||||
allow_empty=True,
|
||||
required=False,
|
||||
@@ -380,7 +453,7 @@ class DocumentTypeSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ColorField(serializers.Field):
|
||||
class DeprecatedColors:
|
||||
COLOURS = (
|
||||
(1, "#a6cee3"),
|
||||
(2, "#1f78b4"),
|
||||
@@ -397,14 +470,21 @@ class ColorField(serializers.Field):
|
||||
(13, "#cccccc"),
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_field(
|
||||
serializers.ChoiceField(
|
||||
choices=DeprecatedColors.COLOURS,
|
||||
),
|
||||
)
|
||||
class ColorField(serializers.Field):
|
||||
def to_internal_value(self, data):
|
||||
for id, color in self.COLOURS:
|
||||
for id, color in DeprecatedColors.COLOURS:
|
||||
if id == data:
|
||||
return color
|
||||
raise serializers.ValidationError
|
||||
|
||||
def to_representation(self, value):
|
||||
for id, color in self.COLOURS:
|
||||
for id, color in DeprecatedColors.COLOURS:
|
||||
if color == value:
|
||||
return id
|
||||
return 1
|
||||
@@ -433,7 +513,7 @@ class TagSerializerVersion1(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
|
||||
|
||||
class TagSerializer(MatchingModelSerializer, OwnedObjectSerializer):
|
||||
def get_text_color(self, obj):
|
||||
def get_text_color(self, obj) -> str:
|
||||
try:
|
||||
h = obj.color.lstrip("#")
|
||||
rgb = tuple(int(h[i : i + 2], 16) / 256 for i in (0, 2, 4))
|
||||
@@ -499,7 +579,7 @@ class CustomFieldSerializer(serializers.ModelSerializer):
|
||||
context = kwargs.get("context")
|
||||
self.api_version = int(
|
||||
context.get("request").version
|
||||
if context.get("request")
|
||||
if context and context.get("request")
|
||||
else settings.REST_FRAMEWORK["DEFAULT_VERSION"],
|
||||
)
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -657,7 +737,7 @@ class CustomFieldInstanceSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
return instance
|
||||
|
||||
def get_value(self, obj: CustomFieldInstance):
|
||||
def get_value(self, obj: CustomFieldInstance) -> str | int | float | dict | None:
|
||||
return obj.value
|
||||
|
||||
def validate(self, data):
|
||||
@@ -808,13 +888,13 @@ class DocumentSerializer(
|
||||
required=False,
|
||||
)
|
||||
|
||||
def get_page_count(self, obj):
|
||||
def get_page_count(self, obj) -> int | None:
|
||||
return obj.page_count
|
||||
|
||||
def get_original_file_name(self, obj):
|
||||
def get_original_file_name(self, obj) -> str | None:
|
||||
return obj.original_filename
|
||||
|
||||
def get_archived_file_name(self, obj):
|
||||
def get_archived_file_name(self, obj) -> str | None:
|
||||
if obj.has_archive_version:
|
||||
return obj.get_public_filename(archive=True)
|
||||
else:
|
||||
@@ -911,7 +991,7 @@ class DocumentSerializer(
|
||||
|
||||
# return full permissions if we're doing a PATCH or PUT
|
||||
context = kwargs.get("context")
|
||||
if (
|
||||
if context is not None and (
|
||||
context.get("request").method == "PATCH"
|
||||
or context.get("request").method == "PUT"
|
||||
):
|
||||
@@ -921,7 +1001,6 @@ class DocumentSerializer(
|
||||
|
||||
class Meta:
|
||||
model = Document
|
||||
depth = 1
|
||||
fields = (
|
||||
"id",
|
||||
"correspondent",
|
||||
@@ -1606,7 +1685,6 @@ class UiSettingsViewSerializer(serializers.ModelSerializer):
|
||||
class TasksViewSerializer(OwnedObjectSerializer):
|
||||
class Meta:
|
||||
model = PaperlessTask
|
||||
depth = 1
|
||||
fields = (
|
||||
"id",
|
||||
"task_id",
|
||||
@@ -1623,7 +1701,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
|
||||
|
||||
type = serializers.SerializerMethodField()
|
||||
|
||||
def get_type(self, obj):
|
||||
def get_type(self, obj) -> str:
|
||||
# just file tasks, for now
|
||||
return "file"
|
||||
|
||||
@@ -1631,7 +1709,7 @@ class TasksViewSerializer(OwnedObjectSerializer):
|
||||
created_doc_re = re.compile(r"New document id (\d+) created")
|
||||
duplicate_doc_re = re.compile(r"It is a duplicate of .* \(#(\d+)\)")
|
||||
|
||||
def get_related_document(self, obj):
|
||||
def get_related_document(self, obj) -> str | None:
|
||||
result = None
|
||||
re = None
|
||||
match obj.status:
|
||||
|
@@ -88,14 +88,14 @@ class TestApiAuth(DirectoriesMixin, APITestCase):
|
||||
)
|
||||
|
||||
def test_api_version_no_auth(self):
|
||||
response = self.client.get("/api/")
|
||||
response = self.client.get("/api/documents/")
|
||||
self.assertNotIn("X-Api-Version", response)
|
||||
self.assertNotIn("X-Version", response)
|
||||
|
||||
def test_api_version_with_auth(self):
|
||||
user = User.objects.create_superuser(username="test")
|
||||
self.client.force_authenticate(user)
|
||||
response = self.client.get("/api/")
|
||||
response = self.client.get("/api/documents/")
|
||||
self.assertIn("X-Api-Version", response)
|
||||
self.assertIn("X-Version", response)
|
||||
|
||||
|
27
src/documents/tests/test_api_schema.py
Normal file
27
src/documents/tests/test_api_schema.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
|
||||
class TestApiSchema(APITestCase):
|
||||
ENDPOINT = "/api/schema/"
|
||||
|
||||
def test_valid_schema(self):
|
||||
"""
|
||||
Test that the schema is valid
|
||||
"""
|
||||
try:
|
||||
call_command("spectacular", "--validate", "--fail-on-warn")
|
||||
except CommandError as e:
|
||||
self.fail(f"Schema validation failed: {e}")
|
||||
|
||||
def test_get_schema_endpoints(self):
|
||||
"""
|
||||
Test that the schema endpoints exist and return a 200 status code
|
||||
"""
|
||||
schema_response = self.client.get(self.ENDPOINT)
|
||||
self.assertEqual(schema_response.status_code, status.HTTP_200_OK)
|
||||
|
||||
ui_response = self.client.get(self.ENDPOINT + "view/")
|
||||
self.assertEqual(ui_response.status_code, status.HTTP_200_OK)
|
@@ -48,10 +48,16 @@ from django.views.decorators.http import condition
|
||||
from django.views.decorators.http import last_modified
|
||||
from django.views.generic import TemplateView
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import OpenApiParameter
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.utils import extend_schema_view
|
||||
from drf_spectacular.utils import inline_serializer
|
||||
from langdetect import detect
|
||||
from packaging import version as packaging_version
|
||||
from redis import Redis
|
||||
from rest_framework import parsers
|
||||
from rest_framework import serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import NotFound
|
||||
from rest_framework.filters import OrderingFilter
|
||||
@@ -63,7 +69,6 @@ from rest_framework.mixins import RetrieveModelMixin
|
||||
from rest_framework.mixins import UpdateModelMixin
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||
@@ -127,6 +132,7 @@ from documents.permissions import PaperlessObjectPermissions
|
||||
from documents.permissions import get_objects_for_user_owner_aware
|
||||
from documents.permissions import has_perms_owner_aware
|
||||
from documents.permissions import set_permissions_for_object
|
||||
from documents.schema import generate_object_with_permissions_schema
|
||||
from documents.serialisers import AcknowledgeTasksViewSerializer
|
||||
from documents.serialisers import BulkDownloadSerializer
|
||||
from documents.serialisers import BulkEditObjectsSerializer
|
||||
@@ -256,6 +262,7 @@ class PermissionsAwareDocumentCountMixin(PassUserMixin):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(CorrespondentSerializer))
|
||||
class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
model = Correspondent
|
||||
|
||||
@@ -292,6 +299,7 @@ class CorrespondentViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
return super().retrieve(request, *args, **kwargs)
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(TagSerializer))
|
||||
class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
model = Tag
|
||||
|
||||
@@ -316,6 +324,7 @@ class TagViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
ordering_fields = ("color", "name", "matching_algorithm", "match", "document_count")
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(DocumentTypeSerializer))
|
||||
class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
model = DocumentType
|
||||
|
||||
@@ -333,6 +342,177 @@ class DocumentTypeViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
ordering_fields = ("name", "matching_algorithm", "match", "document_count")
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
retrieve=extend_schema(
|
||||
description="Retrieve a single document",
|
||||
responses={
|
||||
200: DocumentSerializer(all_fields=True),
|
||||
400: None,
|
||||
},
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="full_perms",
|
||||
type=OpenApiTypes.BOOL,
|
||||
location=OpenApiParameter.QUERY,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="fields",
|
||||
type=OpenApiTypes.STR,
|
||||
many=True,
|
||||
location=OpenApiParameter.QUERY,
|
||||
),
|
||||
],
|
||||
),
|
||||
download=extend_schema(
|
||||
description="Download the document",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="original",
|
||||
type=OpenApiTypes.BOOL,
|
||||
location=OpenApiParameter.QUERY,
|
||||
),
|
||||
],
|
||||
responses={200: OpenApiTypes.BINARY},
|
||||
),
|
||||
history=extend_schema(
|
||||
description="View the document history",
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="LogEntry",
|
||||
many=True,
|
||||
fields={
|
||||
"id": serializers.IntegerField(),
|
||||
"timestamp": serializers.DateTimeField(),
|
||||
"action": serializers.CharField(),
|
||||
"changes": serializers.DictField(),
|
||||
"actor": inline_serializer(
|
||||
name="Actor",
|
||||
fields={
|
||||
"id": serializers.IntegerField(),
|
||||
"username": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
400: None,
|
||||
403: None,
|
||||
404: None,
|
||||
},
|
||||
),
|
||||
metadata=extend_schema(
|
||||
description="View the document metadata",
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="Metadata",
|
||||
fields={
|
||||
"original_checksum": serializers.CharField(),
|
||||
"original_size": serializers.IntegerField(),
|
||||
"original_mime_type": serializers.CharField(),
|
||||
"media_filename": serializers.CharField(),
|
||||
"has_archive_version": serializers.BooleanField(),
|
||||
"original_metadata": serializers.DictField(),
|
||||
"archive_checksum": serializers.CharField(),
|
||||
"archive_media_filename": serializers.CharField(),
|
||||
"original_filename": serializers.CharField(),
|
||||
"archive_size": serializers.IntegerField(),
|
||||
"archive_metadata": serializers.DictField(),
|
||||
"lang": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
400: None,
|
||||
403: None,
|
||||
404: None,
|
||||
},
|
||||
),
|
||||
notes=extend_schema(
|
||||
description="View, add, or delete notes for the document",
|
||||
responses={
|
||||
200: {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"note": {"type": "string"},
|
||||
"created": {"type": "string", "format": "date-time"},
|
||||
"user": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"username": {"type": "string"},
|
||||
"first_name": {"type": "string"},
|
||||
"last_name": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: None,
|
||||
403: None,
|
||||
404: None,
|
||||
},
|
||||
),
|
||||
suggestions=extend_schema(
|
||||
description="View suggestions for the document",
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="Suggestions",
|
||||
fields={
|
||||
"correspondents": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
),
|
||||
"tags": serializers.ListField(child=serializers.IntegerField()),
|
||||
"document_types": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
),
|
||||
"storage_paths": serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
),
|
||||
"dates": serializers.ListField(child=serializers.CharField()),
|
||||
},
|
||||
),
|
||||
400: None,
|
||||
403: None,
|
||||
404: None,
|
||||
},
|
||||
),
|
||||
thumb=extend_schema(
|
||||
description="View the document thumbnail",
|
||||
responses={200: OpenApiTypes.BINARY},
|
||||
),
|
||||
preview=extend_schema(
|
||||
description="View the document preview",
|
||||
responses={200: OpenApiTypes.BINARY},
|
||||
),
|
||||
share_links=extend_schema(
|
||||
operation_id="document_share_links",
|
||||
description="View share links for the document",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="id",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.PATH,
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"created": {"type": "string", "format": "date-time"},
|
||||
"expiration": {"type": "string", "format": "date-time"},
|
||||
"slug": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: None,
|
||||
403: None,
|
||||
404: None,
|
||||
},
|
||||
),
|
||||
)
|
||||
class DocumentViewSet(
|
||||
PassUserMixin,
|
||||
RetrieveModelMixin,
|
||||
@@ -466,7 +646,7 @@ class DocumentViewSet(
|
||||
else:
|
||||
return None
|
||||
|
||||
@action(methods=["get"], detail=True)
|
||||
@action(methods=["get"], detail=True, filter_backends=[])
|
||||
@method_decorator(cache_control(no_cache=True))
|
||||
@method_decorator(
|
||||
condition(etag_func=metadata_etag, last_modified_func=metadata_last_modified),
|
||||
@@ -525,7 +705,7 @@ class DocumentViewSet(
|
||||
|
||||
return Response(meta)
|
||||
|
||||
@action(methods=["get"], detail=True)
|
||||
@action(methods=["get"], detail=True, filter_backends=[])
|
||||
@method_decorator(cache_control(no_cache=True))
|
||||
@method_decorator(
|
||||
condition(
|
||||
@@ -576,7 +756,7 @@ class DocumentViewSet(
|
||||
|
||||
return Response(resp_data)
|
||||
|
||||
@action(methods=["get"], detail=True)
|
||||
@action(methods=["get"], detail=True, filter_backends=[])
|
||||
@method_decorator(cache_control(no_cache=True))
|
||||
@method_decorator(
|
||||
condition(etag_func=preview_etag, last_modified_func=preview_last_modified),
|
||||
@@ -588,7 +768,7 @@ class DocumentViewSet(
|
||||
except (FileNotFoundError, Document.DoesNotExist):
|
||||
raise Http404
|
||||
|
||||
@action(methods=["get"], detail=True)
|
||||
@action(methods=["get"], detail=True, filter_backends=[])
|
||||
@method_decorator(cache_control(no_cache=True))
|
||||
@method_decorator(last_modified(thumbnail_last_modified))
|
||||
def thumb(self, request, pk=None):
|
||||
@@ -647,6 +827,7 @@ class DocumentViewSet(
|
||||
methods=["get", "post", "delete"],
|
||||
detail=True,
|
||||
permission_classes=[PaperlessNotePermissions],
|
||||
filter_backends=[],
|
||||
)
|
||||
def notes(self, request, pk=None):
|
||||
currentUser = request.user
|
||||
@@ -754,7 +935,7 @@ class DocumentViewSet(
|
||||
},
|
||||
)
|
||||
|
||||
@action(methods=["get"], detail=True)
|
||||
@action(methods=["get"], detail=True, filter_backends=[])
|
||||
def share_links(self, request, pk=None):
|
||||
currentUser = request.user
|
||||
try:
|
||||
@@ -772,21 +953,16 @@ class DocumentViewSet(
|
||||
|
||||
if request.method == "GET":
|
||||
now = timezone.now()
|
||||
links = [
|
||||
{
|
||||
"id": c.pk,
|
||||
"created": c.created,
|
||||
"expiration": c.expiration,
|
||||
"slug": c.slug,
|
||||
}
|
||||
for c in ShareLink.objects.filter(document=doc)
|
||||
links = (
|
||||
ShareLink.objects.filter(document=doc)
|
||||
.only("pk", "created", "expiration", "slug")
|
||||
.exclude(expiration__lt=now)
|
||||
.order_by("-created")
|
||||
]
|
||||
return Response(links)
|
||||
)
|
||||
serializer = ShareLinkSerializer(links, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(methods=["get"], detail=True, name="Audit Trail")
|
||||
@action(methods=["get"], detail=True, name="Audit Trail", filter_backends=[])
|
||||
def history(self, request, pk=None):
|
||||
if not settings.AUDIT_LOG_ENABLED:
|
||||
return HttpResponseBadRequest("Audit log is disabled")
|
||||
@@ -848,6 +1024,26 @@ class DocumentViewSet(
|
||||
return Response(sorted(entries, key=lambda x: x["timestamp"], reverse=True))
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="full_perms",
|
||||
type=OpenApiTypes.BOOL,
|
||||
location=OpenApiParameter.QUERY,
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="fields",
|
||||
type=OpenApiTypes.STR,
|
||||
many=True,
|
||||
location=OpenApiParameter.QUERY,
|
||||
),
|
||||
],
|
||||
responses={
|
||||
200: DocumentSerializer(many=True, all_fields=True),
|
||||
},
|
||||
),
|
||||
)
|
||||
class UnifiedSearchViewSet(DocumentViewSet):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -915,6 +1111,33 @@ class UnifiedSearchViewSet(DocumentViewSet):
|
||||
return Response(max_asn + 1)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
description="Logs view",
|
||||
responses={
|
||||
(200, "application/json"): serializers.ListSerializer(
|
||||
child=serializers.CharField(),
|
||||
),
|
||||
},
|
||||
),
|
||||
retrieve=extend_schema(
|
||||
description="Single log view",
|
||||
operation_id="retrieve_log",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="id",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.PATH,
|
||||
),
|
||||
],
|
||||
responses={
|
||||
(200, "application/json"): serializers.ListSerializer(
|
||||
child=serializers.CharField(),
|
||||
),
|
||||
(404, "application/json"): None,
|
||||
},
|
||||
),
|
||||
)
|
||||
class LogViewSet(ViewSet):
|
||||
permission_classes = (IsAuthenticated, PaperlessAdminPermissions)
|
||||
|
||||
@@ -923,11 +1146,12 @@ class LogViewSet(ViewSet):
|
||||
def get_log_filename(self, log):
|
||||
return os.path.join(settings.LOGGING_DIR, f"{log}.log")
|
||||
|
||||
def retrieve(self, request, pk=None, *args, **kwargs):
|
||||
if pk not in self.log_files:
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
log_file = kwargs.get("pk")
|
||||
if log_file not in self.log_files:
|
||||
raise Http404
|
||||
|
||||
filename = self.get_log_filename(pk)
|
||||
filename = self.get_log_filename(log_file)
|
||||
|
||||
if not os.path.isfile(filename):
|
||||
raise Http404
|
||||
@@ -964,6 +1188,24 @@ class SavedViewViewSet(ModelViewSet, PassUserMixin):
|
||||
serializer.save(owner=self.request.user)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
operation_id="bulk_edit",
|
||||
description="Perform a bulk edit operation on a list of documents",
|
||||
external_docs={
|
||||
"description": "Further documentation",
|
||||
"url": "https://docs.paperless-ngx.com/api/#bulk-editing",
|
||||
},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="BulkEditDocumentsResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class BulkEditView(PassUserMixin):
|
||||
MODIFIED_FIELD_BY_METHOD = {
|
||||
"set_correspondent": "correspondent",
|
||||
@@ -1113,6 +1355,18 @@ class BulkEditView(PassUserMixin):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
description="Upload a document via the API",
|
||||
external_docs={
|
||||
"description": "Further documentation",
|
||||
"url": "https://docs.paperless-ngx.com/api/#file-uploads",
|
||||
},
|
||||
responses={
|
||||
(200, "application/json"): OpenApiTypes.STR,
|
||||
},
|
||||
),
|
||||
)
|
||||
class PostDocumentView(GenericAPIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = PostDocumentSerializer
|
||||
@@ -1169,6 +1423,63 @@ class PostDocumentView(GenericAPIView):
|
||||
return Response(async_task.id)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
description="Get selection data for the selected documents",
|
||||
responses={
|
||||
(200, "application/json"): inline_serializer(
|
||||
name="SelectionData",
|
||||
fields={
|
||||
"selected_correspondents": serializers.ListSerializer(
|
||||
child=inline_serializer(
|
||||
name="CorrespondentCounts",
|
||||
fields={
|
||||
"id": serializers.IntegerField(),
|
||||
"document_count": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
),
|
||||
"selected_tags": serializers.ListSerializer(
|
||||
child=inline_serializer(
|
||||
name="TagCounts",
|
||||
fields={
|
||||
"id": serializers.IntegerField(),
|
||||
"document_count": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
),
|
||||
"selected_document_types": serializers.ListSerializer(
|
||||
child=inline_serializer(
|
||||
name="DocumentTypeCounts",
|
||||
fields={
|
||||
"id": serializers.IntegerField(),
|
||||
"document_count": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
),
|
||||
"selected_storage_paths": serializers.ListSerializer(
|
||||
child=inline_serializer(
|
||||
name="StoragePathCounts",
|
||||
fields={
|
||||
"id": serializers.IntegerField(),
|
||||
"document_count": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
),
|
||||
"selected_custom_fields": serializers.ListSerializer(
|
||||
child=inline_serializer(
|
||||
name="CustomFieldCounts",
|
||||
fields={
|
||||
"id": serializers.IntegerField(),
|
||||
"document_count": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class SelectionDataView(GenericAPIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = DocumentListSerializer
|
||||
@@ -1242,7 +1553,31 @@ class SelectionDataView(GenericAPIView):
|
||||
return r
|
||||
|
||||
|
||||
class SearchAutoCompleteView(APIView):
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
description="Get a list of all available tags",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="term",
|
||||
required=False,
|
||||
type=str,
|
||||
description="Term to search for",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="limit",
|
||||
required=False,
|
||||
type=int,
|
||||
description="Number of completions to return",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
(200, "application/json"): serializers.ListSerializer(
|
||||
child=serializers.CharField(),
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class SearchAutoCompleteView(GenericAPIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request, format=None):
|
||||
@@ -1274,6 +1609,45 @@ class SearchAutoCompleteView(APIView):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
description="Global search",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="query",
|
||||
required=True,
|
||||
type=str,
|
||||
description="Query to search for",
|
||||
),
|
||||
OpenApiParameter(
|
||||
name="db_only",
|
||||
required=False,
|
||||
type=bool,
|
||||
description="Search only the database",
|
||||
),
|
||||
],
|
||||
responses={
|
||||
(200, "application/json"): inline_serializer(
|
||||
name="SearchResult",
|
||||
fields={
|
||||
"total": serializers.IntegerField(),
|
||||
"documents": DocumentSerializer(many=True),
|
||||
"saved_views": SavedViewSerializer(many=True),
|
||||
"tags": TagSerializer(many=True),
|
||||
"correspondents": CorrespondentSerializer(many=True),
|
||||
"document_types": DocumentTypeSerializer(many=True),
|
||||
"storage_paths": StoragePathSerializer(many=True),
|
||||
"users": UserSerializer(many=True),
|
||||
"groups": GroupSerializer(many=True),
|
||||
"mail_rules": MailRuleSerializer(many=True),
|
||||
"mail_accounts": MailAccountSerializer(many=True),
|
||||
"workflows": WorkflowSerializer(many=True),
|
||||
"custom_fields": CustomFieldSerializer(many=True),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class GlobalSearchView(PassUserMixin):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = SearchResultSerializer
|
||||
@@ -1469,7 +1843,15 @@ class GlobalSearchView(PassUserMixin):
|
||||
)
|
||||
|
||||
|
||||
class StatisticsView(APIView):
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
description="Get statistics for the current user",
|
||||
responses={
|
||||
(200, "application/json"): OpenApiTypes.OBJECT,
|
||||
},
|
||||
),
|
||||
)
|
||||
class StatisticsView(GenericAPIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
def get(self, request, format=None):
|
||||
@@ -1623,6 +2005,7 @@ class BulkDownloadView(GenericAPIView):
|
||||
return response
|
||||
|
||||
|
||||
@extend_schema_view(**generate_object_with_permissions_schema(StoragePathSerializer))
|
||||
class StoragePathViewSet(ModelViewSet, PermissionsAwareDocumentCountMixin):
|
||||
model = StoragePath
|
||||
|
||||
@@ -1762,6 +2145,14 @@ class UiSettingsView(GenericAPIView):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
description="Get the current version of the Paperless-NGX server",
|
||||
responses={
|
||||
(200, "application/json"): OpenApiTypes.OBJECT,
|
||||
},
|
||||
),
|
||||
)
|
||||
class RemoteVersionView(GenericAPIView):
|
||||
def get(self, request, format=None):
|
||||
remote_version = "0.0.0"
|
||||
@@ -1802,6 +2193,33 @@ class RemoteVersionView(GenericAPIView):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
acknowledge=extend_schema(
|
||||
operation_id="acknowledge_tasks",
|
||||
description="Acknowledge a list of tasks",
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tasks": {
|
||||
"type": "array",
|
||||
"items": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
"required": ["tasks"],
|
||||
},
|
||||
},
|
||||
responses={
|
||||
(200, "application/json"): inline_serializer(
|
||||
name="AcknowledgeTasks",
|
||||
fields={
|
||||
"result": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
(400, "application/json"): None,
|
||||
},
|
||||
),
|
||||
)
|
||||
class TasksViewSet(ReadOnlyModelViewSet):
|
||||
permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
|
||||
serializer_class = TasksViewSerializer
|
||||
@@ -1907,6 +2325,24 @@ def serve_file(*, doc: Document, use_archive: bool, disposition: str):
|
||||
return response
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
operation_id="bulk_edit_objects",
|
||||
description="Perform a bulk edit operation on a list of objects",
|
||||
external_docs={
|
||||
"description": "Further documentation",
|
||||
"url": "https://docs.paperless-ngx.com/api/#objects",
|
||||
},
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="BulkEditResult",
|
||||
fields={
|
||||
"result": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class BulkEditObjectsView(PassUserMixin):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
serializer_class = BulkEditObjectsSerializer
|
||||
@@ -2065,6 +2501,71 @@ class CustomFieldViewSet(ModelViewSet):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
description="Get the current system status of the Paperless-NGX server",
|
||||
responses={
|
||||
(200, "application/json"): inline_serializer(
|
||||
name="SystemStatus",
|
||||
fields={
|
||||
"pngx_version": serializers.CharField(),
|
||||
"server_os": serializers.CharField(),
|
||||
"install_type": serializers.CharField(),
|
||||
"storage": inline_serializer(
|
||||
name="Storage",
|
||||
fields={
|
||||
"total": serializers.IntegerField(),
|
||||
"available": serializers.IntegerField(),
|
||||
},
|
||||
),
|
||||
"database": inline_serializer(
|
||||
name="Database",
|
||||
fields={
|
||||
"type": serializers.CharField(),
|
||||
"url": serializers.CharField(),
|
||||
"status": serializers.CharField(),
|
||||
"error": serializers.CharField(),
|
||||
"migration_status": inline_serializer(
|
||||
name="MigrationStatus",
|
||||
fields={
|
||||
"latest_migration": serializers.CharField(),
|
||||
"unapplied_migrations": serializers.ListSerializer(
|
||||
child=serializers.CharField(),
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
"tasks": inline_serializer(
|
||||
name="Tasks",
|
||||
fields={
|
||||
"redis_url": serializers.CharField(),
|
||||
"redis_status": serializers.CharField(),
|
||||
"redis_error": serializers.CharField(),
|
||||
"celery_status": serializers.CharField(),
|
||||
},
|
||||
),
|
||||
"index": inline_serializer(
|
||||
name="Index",
|
||||
fields={
|
||||
"status": serializers.CharField(),
|
||||
"error": serializers.CharField(),
|
||||
"last_modified": serializers.DateTimeField(),
|
||||
},
|
||||
),
|
||||
"classifier": inline_serializer(
|
||||
name="Classifier",
|
||||
fields={
|
||||
"status": serializers.CharField(),
|
||||
"error": serializers.CharField(),
|
||||
"last_trained": serializers.DateTimeField(),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
class SystemStatusView(PassUserMixin):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
|
@@ -11,22 +11,11 @@ from rest_framework import serializers
|
||||
from rest_framework.authtoken.serializers import AuthTokenSerializer
|
||||
|
||||
from paperless.models import ApplicationConfiguration
|
||||
from paperless_mail.serialisers import ObfuscatedPasswordField
|
||||
|
||||
logger = logging.getLogger("paperless.settings")
|
||||
|
||||
|
||||
class ObfuscatedUserPasswordField(serializers.Field):
|
||||
"""
|
||||
Sends *** string instead of password in the clear
|
||||
"""
|
||||
|
||||
def to_representation(self, value):
|
||||
return "**********" if len(value) > 0 else ""
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return data
|
||||
|
||||
|
||||
class PaperlessAuthTokenSerializer(AuthTokenSerializer):
|
||||
code = serializers.CharField(
|
||||
label="MFA Code",
|
||||
@@ -58,7 +47,7 @@ class PaperlessAuthTokenSerializer(AuthTokenSerializer):
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
password = ObfuscatedUserPasswordField(required=False)
|
||||
password = ObfuscatedPasswordField(required=False)
|
||||
user_permissions = serializers.SlugRelatedField(
|
||||
many=True,
|
||||
queryset=Permission.objects.exclude(content_type__app_label="admin"),
|
||||
@@ -68,7 +57,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
inherited_permissions = serializers.SerializerMethodField()
|
||||
is_mfa_enabled = serializers.SerializerMethodField()
|
||||
|
||||
def get_is_mfa_enabled(self, user: User):
|
||||
def get_is_mfa_enabled(self, user: User) -> bool:
|
||||
mfa_adapter = get_mfa_adapter()
|
||||
return mfa_adapter.is_mfa_enabled(user)
|
||||
|
||||
@@ -91,7 +80,7 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
"is_mfa_enabled",
|
||||
)
|
||||
|
||||
def get_inherited_permissions(self, obj):
|
||||
def get_inherited_permissions(self, obj) -> list[str]:
|
||||
return obj.get_group_permissions()
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
@@ -157,13 +146,13 @@ class SocialAccountSerializer(serializers.ModelSerializer):
|
||||
"name",
|
||||
)
|
||||
|
||||
def get_name(self, obj):
|
||||
def get_name(self, obj) -> str:
|
||||
return obj.get_provider_account().to_str()
|
||||
|
||||
|
||||
class ProfileSerializer(serializers.ModelSerializer):
|
||||
email = serializers.EmailField(allow_blank=True, required=False)
|
||||
password = ObfuscatedUserPasswordField(required=False, allow_null=False)
|
||||
password = ObfuscatedPasswordField(required=False, allow_null=False)
|
||||
auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key")
|
||||
social_accounts = SocialAccountSerializer(
|
||||
many=True,
|
||||
@@ -171,11 +160,15 @@ class ProfileSerializer(serializers.ModelSerializer):
|
||||
source="socialaccount_set",
|
||||
)
|
||||
is_mfa_enabled = serializers.SerializerMethodField()
|
||||
has_usable_password = serializers.SerializerMethodField()
|
||||
|
||||
def get_is_mfa_enabled(self, user: User):
|
||||
def get_is_mfa_enabled(self, user: User) -> bool:
|
||||
mfa_adapter = get_mfa_adapter()
|
||||
return mfa_adapter.is_mfa_enabled(user)
|
||||
|
||||
def get_has_usable_password(self, user: User) -> bool:
|
||||
return user.has_usable_password()
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = (
|
||||
|
@@ -328,6 +328,8 @@ INSTALLED_APPS = [
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"allauth.mfa",
|
||||
"drf_spectacular",
|
||||
"drf_spectacular_sidecar",
|
||||
*env_apps,
|
||||
]
|
||||
|
||||
@@ -345,6 +347,25 @@ REST_FRAMEWORK = {
|
||||
# Make sure these are ordered and that the most recent version appears
|
||||
# last. See api.md#api-versioning when adding new versions.
|
||||
"ALLOWED_VERSIONS": ["1", "2", "3", "4", "5", "6", "7"],
|
||||
# DRF Spectacular default schema
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
}
|
||||
|
||||
# DRF Spectacular settings
|
||||
SPECTACULAR_SETTINGS = {
|
||||
"TITLE": "Paperless-ngx REST API",
|
||||
"DESCRIPTION": "OpenAPI Spec for Paperless-ngx",
|
||||
"VERSION": "6.0.0",
|
||||
"SERVE_INCLUDE_SCHEMA": False,
|
||||
"SWAGGER_UI_DIST": "SIDECAR",
|
||||
"COMPONENT_SPLIT_REQUEST": True,
|
||||
"EXTERNAL_DOCS": {
|
||||
"description": "Paperless-ngx API Documentation",
|
||||
"url": "https://docs.paperless-ngx.com/api/",
|
||||
},
|
||||
"ENUM_NAME_OVERRIDES": {
|
||||
"MatchingAlgorithm": "documents.models.MatchingModel.MATCHING_ALGORITHMS",
|
||||
},
|
||||
}
|
||||
|
||||
if DEBUG:
|
||||
|
@@ -14,6 +14,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.generic import RedirectView
|
||||
from django.views.static import serve
|
||||
from drf_spectacular.views import SpectacularAPIView
|
||||
from drf_spectacular.views import SpectacularSwaggerView
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from documents.views import BulkDownloadView
|
||||
@@ -203,6 +205,27 @@ urlpatterns = [
|
||||
OauthCallbackView.as_view(),
|
||||
name="oauth_callback",
|
||||
),
|
||||
re_path(
|
||||
"^schema/",
|
||||
include(
|
||||
[
|
||||
re_path(
|
||||
"^$",
|
||||
SpectacularAPIView.as_view(),
|
||||
name="schema",
|
||||
),
|
||||
re_path(
|
||||
"^view/",
|
||||
SpectacularSwaggerView.as_view(),
|
||||
name="swagger-ui",
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
re_path(
|
||||
"^$", # Redirect to the API swagger view
|
||||
RedirectView.as_view(url="schema/view/"),
|
||||
),
|
||||
*api_router.urls,
|
||||
],
|
||||
),
|
||||
|
@@ -18,6 +18,9 @@ from django.http import HttpResponseForbidden
|
||||
from django.http import HttpResponseNotFound
|
||||
from django.views.generic import View
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.utils import extend_schema_view
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.authtoken.views import ObtainAuthToken
|
||||
from rest_framework.decorators import action
|
||||
@@ -27,7 +30,6 @@ from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.permissions import DjangoModelPermissions
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from documents.permissions import PaperlessObjectPermissions
|
||||
@@ -197,6 +199,34 @@ class ProfileView(GenericAPIView):
|
||||
return Response(serializer.to_representation(user))
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
responses={
|
||||
(200, "application/json"): OpenApiTypes.OBJECT,
|
||||
},
|
||||
),
|
||||
post=extend_schema(
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"secret": {"type": "string"},
|
||||
"code": {"type": "string"},
|
||||
},
|
||||
"required": ["secret", "code"],
|
||||
},
|
||||
},
|
||||
responses={
|
||||
(200, "application/json"): OpenApiTypes.OBJECT,
|
||||
},
|
||||
),
|
||||
delete=extend_schema(
|
||||
responses={
|
||||
(200, "application/json"): OpenApiTypes.BOOL,
|
||||
404: OpenApiTypes.STR,
|
||||
},
|
||||
),
|
||||
)
|
||||
class TOTPView(GenericAPIView):
|
||||
"""
|
||||
TOTP views
|
||||
@@ -267,6 +297,16 @@ class TOTPView(GenericAPIView):
|
||||
return HttpResponseNotFound("TOTP not found")
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
request={
|
||||
"application/json": None,
|
||||
},
|
||||
responses={
|
||||
(200, "application/json"): OpenApiTypes.STR,
|
||||
},
|
||||
),
|
||||
)
|
||||
class GenerateAuthTokenView(GenericAPIView):
|
||||
"""
|
||||
Generates (or re-generates) an auth token, requires a logged in user
|
||||
@@ -287,6 +327,15 @@ class GenerateAuthTokenView(GenericAPIView):
|
||||
)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
list=extend_schema(
|
||||
description="Get the application configuration",
|
||||
external_docs={
|
||||
"description": "Application Configuration",
|
||||
"url": "https://docs.paperless-ngx.com/configuration/",
|
||||
},
|
||||
),
|
||||
)
|
||||
class ApplicationConfigurationViewSet(ModelViewSet):
|
||||
model = ApplicationConfiguration
|
||||
|
||||
@@ -296,6 +345,23 @@ class ApplicationConfigurationViewSet(ModelViewSet):
|
||||
permission_classes = (IsAuthenticated, DjangoModelPermissions)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
post=extend_schema(
|
||||
request={
|
||||
"application/json": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
},
|
||||
"required": ["id"],
|
||||
},
|
||||
},
|
||||
responses={
|
||||
(200, "application/json"): OpenApiTypes.INT,
|
||||
400: OpenApiTypes.STR,
|
||||
},
|
||||
),
|
||||
)
|
||||
class DisconnectSocialAccountView(GenericAPIView):
|
||||
"""
|
||||
Disconnects a social account provider from the user account
|
||||
@@ -315,7 +381,14 @@ class DisconnectSocialAccountView(GenericAPIView):
|
||||
return HttpResponseBadRequest("Social account not found")
|
||||
|
||||
|
||||
class SocialAccountProvidersView(APIView):
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
responses={
|
||||
(200, "application/json"): OpenApiTypes.OBJECT,
|
||||
},
|
||||
),
|
||||
)
|
||||
class SocialAccountProvidersView(GenericAPIView):
|
||||
"""
|
||||
List of social account providers
|
||||
"""
|
||||
|
@@ -8,13 +8,13 @@ from paperless_mail.models import MailAccount
|
||||
from paperless_mail.models import MailRule
|
||||
|
||||
|
||||
class ObfuscatedPasswordField(serializers.Field):
|
||||
class ObfuscatedPasswordField(serializers.CharField):
|
||||
"""
|
||||
Sends *** string instead of password in the clear
|
||||
"""
|
||||
|
||||
def to_representation(self, value):
|
||||
return "*" * len(value)
|
||||
def to_representation(self, value) -> str:
|
||||
return "*" * max(10, len(value))
|
||||
|
||||
def to_internal_value(self, data):
|
||||
return data
|
||||
|
@@ -64,7 +64,7 @@ class TestAPIMailAccounts(DirectoriesMixin, APITestCase):
|
||||
self.assertEqual(returned_account1["username"], account1.username)
|
||||
self.assertEqual(
|
||||
returned_account1["password"],
|
||||
"*" * len(account1.password),
|
||||
"**********",
|
||||
)
|
||||
self.assertEqual(returned_account1["imap_server"], account1.imap_server)
|
||||
self.assertEqual(returned_account1["imap_port"], account1.imap_port)
|
||||
|
@@ -5,7 +5,12 @@ from datetime import timedelta
|
||||
from django.http import HttpResponseBadRequest
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.utils import timezone
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from drf_spectacular.utils import extend_schema_view
|
||||
from drf_spectacular.utils import inline_serializer
|
||||
from httpx_oauth.oauth2 import GetAccessTokenError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
@@ -27,6 +32,19 @@ from paperless_mail.serialisers import MailRuleSerializer
|
||||
from paperless_mail.tasks import process_mail_accounts
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
test=extend_schema(
|
||||
operation_id="mail_account_test",
|
||||
description="Test a mail account",
|
||||
responses={
|
||||
200: inline_serializer(
|
||||
name="MailAccountTestResponse",
|
||||
fields={"success": serializers.BooleanField()},
|
||||
),
|
||||
400: OpenApiTypes.STR,
|
||||
},
|
||||
),
|
||||
)
|
||||
class MailAccountViewSet(ModelViewSet, PassUserMixin):
|
||||
model = MailAccount
|
||||
|
||||
@@ -106,6 +124,12 @@ class MailRuleViewSet(ModelViewSet, PassUserMixin):
|
||||
filter_backends = (ObjectOwnedOrGrantedPermissionsFilter,)
|
||||
|
||||
|
||||
@extend_schema_view(
|
||||
get=extend_schema(
|
||||
description="Callback view for OAuth2 authentication",
|
||||
responses={200: None},
|
||||
),
|
||||
)
|
||||
class OauthCallbackView(GenericAPIView):
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
|
Reference in New Issue
Block a user