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

This commit is contained in:
shamoon
2025-02-10 08:43:07 -08:00
committed by GitHub
parent c316ae369b
commit 1dc80f04cb
19 changed files with 1048 additions and 255 deletions

View File

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

View File

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

44
src/documents/schema.py Normal file
View File

@@ -0,0 +1,44 @@
from drf_spectacular.extensions import OpenApiAuthenticationExtension
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter
from drf_spectacular.utils import extend_schema
class AngularApiAuthenticationOverrideScheme(OpenApiAuthenticationExtension):
target_class = "paperless.auth.AngularApiAuthenticationOverride"
name = "AngularApiAuthenticationOverride"
def get_security_definition(self, auto_schema): # pragma: no cover
return {
"type": "http",
"scheme": "basic",
}
class PaperelessBasicAuthenticationScheme(OpenApiAuthenticationExtension):
target_class = "paperless.auth.PaperlessBasicAuthentication"
name = "PaperelessBasicAuthentication"
def get_security_definition(self, auto_schema):
return {
"type": "http",
"scheme": "basic",
}
def generate_object_with_permissions_schema(serializer_class):
return {
operation: extend_schema(
parameters=[
OpenApiParameter(
name="full_perms",
type=OpenApiTypes.BOOL,
location=OpenApiParameter.QUERY,
),
],
responses={
200: serializer_class(many=operation == "list", all_fields=True),
},
)
for operation in ["list", "retrieve"]
}

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
from django.core.management import call_command
from django.core.management.base import CommandError
from rest_framework import status
from rest_framework.test import APITestCase
class TestApiSchema(APITestCase):
ENDPOINT = "/api/schema/"
def test_valid_schema(self):
"""
Test that the schema is valid
"""
try:
call_command("spectacular", "--validate", "--fail-on-warn")
except CommandError as e:
self.fail(f"Schema validation failed: {e}")
def test_get_schema_endpoints(self):
"""
Test that the schema endpoints exist and return a 200 status code
"""
schema_response = self.client.get(self.ENDPOINT)
self.assertEqual(schema_response.status_code, status.HTTP_200_OK)
ui_response = self.client.get(self.ENDPOINT + "view/")
self.assertEqual(ui_response.status_code, status.HTTP_200_OK)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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