diff --git a/src-ui/src/app/data/share-bundle.ts b/src-ui/src/app/data/share-bundle.ts index f389b6f45..4eb6f5744 100644 --- a/src-ui/src/app/data/share-bundle.ts +++ b/src-ui/src/app/data/share-bundle.ts @@ -12,6 +12,7 @@ export interface ShareBundleSummary { slug: string created: string // Date expiration?: string // Date + documents: number[] document_count: number file_version: FileVersion status: ShareBundleStatus diff --git a/src/documents/admin.py b/src/documents/admin.py index c6f179e2a..ca4fe8723 100644 --- a/src/documents/admin.py +++ b/src/documents/admin.py @@ -12,6 +12,7 @@ from documents.models import Note from documents.models import PaperlessTask from documents.models import SavedView from documents.models import SavedViewFilterRule +from documents.models import ShareBundle from documents.models import ShareLink from documents.models import StoragePath from documents.models import Tag @@ -185,6 +186,22 @@ class ShareLinksAdmin(GuardedModelAdmin): return super().get_queryset(request).select_related("document__correspondent") +class ShareBundleAdmin(GuardedModelAdmin): + list_display = ("created", "status", "expiration", "owner", "slug") + list_filter = ("status", "created", "expiration", "owner") + search_fields = ("slug",) + + def get_queryset(self, request): # pragma: no cover + return ( + super() + .get_queryset(request) + .select_related("owner") + .prefetch_related( + "documents", + ) + ) + + class CustomFieldsAdmin(GuardedModelAdmin): fields = ("name", "created", "data_type") readonly_fields = ("created", "data_type") @@ -216,6 +233,7 @@ admin.site.register(StoragePath, StoragePathAdmin) admin.site.register(PaperlessTask, TaskAdmin) admin.site.register(Note, NotesAdmin) admin.site.register(ShareLink, ShareLinksAdmin) +admin.site.register(ShareBundle, ShareBundleAdmin) admin.site.register(CustomField, CustomFieldsAdmin) admin.site.register(CustomFieldInstance, CustomFieldInstancesAdmin) diff --git a/src/documents/filters.py b/src/documents/filters.py index 3a8d4d327..289d49d0d 100644 --- a/src/documents/filters.py +++ b/src/documents/filters.py @@ -38,6 +38,7 @@ from documents.models import CustomFieldInstance from documents.models import Document from documents.models import DocumentType from documents.models import PaperlessTask +from documents.models import ShareBundle from documents.models import ShareLink from documents.models import StoragePath from documents.models import Tag @@ -793,6 +794,29 @@ class ShareLinkFilterSet(FilterSet): } +class ShareBundleFilterSet(FilterSet): + documents = Filter(method="filter_documents") + + class Meta: + model = ShareBundle + fields = { + "created": DATETIME_KWARGS, + "expiration": DATETIME_KWARGS, + "status": ["exact"], + } + + def filter_documents(self, queryset, name, value): + if not value: + return queryset + try: + ids = [int(item) for item in value.split(",") if item] + except ValueError: + return queryset.none() + if not ids: + return queryset + return queryset.filter(documents__in=ids).distinct() + + class PaperlessTaskFilterSet(FilterSet): acknowledged = BooleanFilter( label="Acknowledged", diff --git a/src/documents/migrations/1075_sharebundle.py b/src/documents/migrations/1075_sharebundle.py new file mode 100644 index 000000000..9a6611205 --- /dev/null +++ b/src/documents/migrations/1075_sharebundle.py @@ -0,0 +1,174 @@ +# Generated by Django 5.2.7 on 2025-11-04 18:34 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.contrib.auth.management import create_permissions +from django.contrib.auth.models import Group +from django.contrib.auth.models import Permission +from django.contrib.auth.models import User +from django.db import migrations +from django.db import models + + +def grant_sharebundle_permissions(apps, schema_editor): + # Ensure newly introduced permissions are created for all apps + for app_config in apps.get_app_configs(): + app_config.models_module = True + create_permissions(app_config, apps=apps, verbosity=0) + app_config.models_module = None + + add_document_perm = Permission.objects.filter(codename="add_document").first() + if add_document_perm is None: + return + + sharebundle_permissions = Permission.objects.filter( + codename__contains="sharebundle", + ) + + users = User.objects.filter(user_permissions=add_document_perm).distinct() + for user in users: + user.user_permissions.add(*sharebundle_permissions) + + groups = Group.objects.filter(permissions=add_document_perm).distinct() + for group in groups: + group.permissions.add(*sharebundle_permissions) + + +def revoke_sharebundle_permissions(apps, schema_editor): + sharebundle_permissions = Permission.objects.filter( + codename__contains="sharebundle", + ) + for user in User.objects.all(): + user.user_permissions.remove(*sharebundle_permissions) + for group in Group.objects.all(): + group.permissions.remove(*sharebundle_permissions) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("documents", "1074_workflowrun_deleted_at_workflowrun_restored_at_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="ShareBundle", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + models.DateTimeField( + blank=True, + db_index=True, + default=django.utils.timezone.now, + editable=False, + verbose_name="created", + ), + ), + ( + "expiration", + models.DateTimeField( + blank=True, + db_index=True, + null=True, + verbose_name="expiration", + ), + ), + ( + "slug", + models.SlugField( + blank=True, + editable=False, + unique=True, + verbose_name="slug", + ), + ), + ( + "file_version", + models.CharField( + choices=[("archive", "Archive"), ("original", "Original")], + default="archive", + max_length=50, + ), + ), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("processing", "Processing"), + ("ready", "Ready"), + ("failed", "Failed"), + ], + default="pending", + max_length=50, + ), + ), + ( + "size_bytes", + models.BigIntegerField( + blank=True, + null=True, + verbose_name="size (bytes)", + ), + ), + ( + "last_error", + models.TextField( + blank=True, + verbose_name="last error", + ), + ), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="share_bundles", + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), + ( + "deleted_at", + models.DateTimeField(blank=True, null=True), + ), + ( + "restored_at", + models.DateTimeField(blank=True, null=True), + ), + ( + "transaction_id", + models.UUIDField(blank=True, null=True), + ), + ], + options={ + "ordering": ("-created",), + "verbose_name": "share bundle", + "verbose_name_plural": "share bundles", + }, + ), + migrations.AddField( + model_name="sharebundle", + name="documents", + field=models.ManyToManyField( + related_name="share_bundles", + to="documents.document", + verbose_name="documents", + ), + ), + migrations.RunPython( + grant_sharebundle_permissions, + reverse_code=revoke_sharebundle_permissions, + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index 12dab2b6d..ec5fdc731 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -777,6 +777,83 @@ class ShareLink(SoftDeleteModel): return f"Share Link for {self.document.title}" +class ShareBundle(SoftDeleteModel): + class Status(models.TextChoices): + PENDING = ("pending", _("Pending")) + PROCESSING = ("processing", _("Processing")) + READY = ("ready", _("Ready")) + FAILED = ("failed", _("Failed")) + + class Meta: + ordering = ("-created",) + verbose_name = _("share bundle") + verbose_name_plural = _("share bundles") + + created = models.DateTimeField( + _("created"), + default=timezone.now, + db_index=True, + blank=True, + editable=False, + ) + + expiration = models.DateTimeField( + _("expiration"), + blank=True, + null=True, + db_index=True, + ) + + slug = models.SlugField( + _("slug"), + db_index=True, + unique=True, + blank=True, + editable=False, + ) + + owner = models.ForeignKey( + User, + blank=True, + null=True, + related_name="share_bundles", + on_delete=models.SET_NULL, + verbose_name=_("owner"), + ) + + file_version = models.CharField( + max_length=50, + choices=ShareLink.FileVersion.choices, + default=ShareLink.FileVersion.ARCHIVE, + ) + + status = models.CharField( + max_length=50, + choices=Status.choices, + default=Status.PENDING, + ) + + size_bytes = models.BigIntegerField( + _("size (bytes)"), + blank=True, + null=True, + ) + + last_error = models.TextField( + _("last error"), + blank=True, + ) + + documents = models.ManyToManyField( + "documents.Document", + related_name="share_bundles", + verbose_name=_("documents"), + ) + + def __str__(self): + return _("Share bundle %(slug)s") % {"slug": self.slug} + + class CustomField(models.Model): """ Defines the name and type of a custom field diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index f04bb70da..6fa2c6817 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -4,6 +4,7 @@ import logging import math import re from datetime import datetime +from datetime import timedelta from decimal import Decimal from typing import TYPE_CHECKING from typing import Literal @@ -21,6 +22,7 @@ from django.core.validators import MaxLengthValidator from django.core.validators import RegexValidator from django.core.validators import integer_validator from django.db.models import Count +from django.utils import timezone from django.utils.crypto import get_random_string from django.utils.dateparse import parse_datetime from django.utils.text import slugify @@ -56,6 +58,7 @@ from documents.models import Note from documents.models import PaperlessTask from documents.models import SavedView from documents.models import SavedViewFilterRule +from documents.models import ShareBundle from documents.models import ShareLink from documents.models import StoragePath from documents.models import Tag @@ -2127,6 +2130,106 @@ class ShareLinkSerializer(OwnedObjectSerializer): return super().create(validated_data) +class ShareBundleSerializer(OwnedObjectSerializer): + document_ids = serializers.ListField( + child=serializers.IntegerField(min_value=1), + allow_empty=False, + write_only=True, + ) + expiration_days = serializers.IntegerField( + required=False, + allow_null=True, + min_value=1, + write_only=True, + ) + documents = serializers.PrimaryKeyRelatedField( + many=True, + read_only=True, + ) + document_count = SerializerMethodField() + + class Meta: + model = ShareBundle + fields = ( + "id", + "created", + "expiration", + "expiration_days", + "slug", + "file_version", + "status", + "size_bytes", + "last_error", + "documents", + "document_ids", + "document_count", + ) + read_only_fields = ( + "id", + "created", + "expiration", + "slug", + "status", + "size_bytes", + "last_error", + "documents", + "document_count", + ) + + def validate_document_ids(self, value): + unique_ids = set(value) + if len(unique_ids) != len(value): + raise serializers.ValidationError( + _("Duplicate document identifiers are not allowed."), + ) + return value + + def create(self, validated_data): + document_ids = validated_data.pop("document_ids") + expiration_days = validated_data.pop("expiration_days", None) + documents = validated_data.pop("documents", None) + validated_data["slug"] = get_random_string(50) + if expiration_days: + validated_data["expiration"] = timezone.now() + timedelta( + days=expiration_days, + ) + else: + validated_data["expiration"] = None + + share_bundle = super().create(validated_data) + + if documents is None: + documents = list( + Document.objects.filter(pk__in=document_ids).only( + "pk", + ), + ) + else: + documents = list(documents) + + documents_by_id = {doc.pk: doc for doc in documents} + missing = [ + str(doc_id) for doc_id in document_ids if doc_id not in documents_by_id + ] + if missing: + raise serializers.ValidationError( + { + "document_ids": _( + "Documents not found: %(ids)s", + ) + % {"ids": ", ".join(missing)}, + }, + ) + + ordered_documents = [documents_by_id[doc_id] for doc_id in document_ids] + share_bundle.documents.set(ordered_documents) + + return share_bundle + + def get_document_count(self, obj: ShareBundle) -> int: + return obj.documents.count() + + class BulkEditObjectsSerializer(SerializerWithPerms, SetPermissionsMixin): objects = serializers.ListField( required=True, diff --git a/src/documents/views.py b/src/documents/views.py index 822647fdb..05a2fb0da 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -50,6 +50,7 @@ from django.utils import timezone from django.utils.decorators import method_decorator from django.utils.timezone import make_aware from django.utils.translation import get_language +from django.utils.translation import gettext_lazy as _ from django.views import View from django.views.decorators.cache import cache_control from django.views.decorators.http import condition @@ -69,6 +70,7 @@ from packaging import version as packaging_version from redis import Redis from rest_framework import parsers from rest_framework import serializers +from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import NotFound from rest_framework.exceptions import ValidationError @@ -117,6 +119,7 @@ from documents.filters import DocumentTypeFilterSet from documents.filters import ObjectOwnedOrGrantedPermissionsFilter from documents.filters import ObjectOwnedPermissionsFilter from documents.filters import PaperlessTaskFilterSet +from documents.filters import ShareBundleFilterSet from documents.filters import ShareLinkFilterSet from documents.filters import StoragePathFilterSet from documents.filters import TagFilterSet @@ -132,6 +135,7 @@ from documents.models import DocumentType from documents.models import Note from documents.models import PaperlessTask from documents.models import SavedView +from documents.models import ShareBundle from documents.models import ShareLink from documents.models import StoragePath from documents.models import Tag @@ -166,6 +170,7 @@ from documents.serialisers import PostDocumentSerializer from documents.serialisers import RunTaskViewSerializer from documents.serialisers import SavedViewSerializer from documents.serialisers import SearchResultSerializer +from documents.serialisers import ShareBundleSerializer from documents.serialisers import ShareLinkSerializer from documents.serialisers import StoragePathSerializer from documents.serialisers import StoragePathTestSerializer @@ -2251,7 +2256,7 @@ class BulkDownloadView(GenericAPIView): follow_filename_format = serializer.validated_data.get("follow_formatting") for document in documents: - if not has_perms_owner_aware(request.user, "view_document", document): + if not has_perms_owner_aware(request.user, "change_document", document): return HttpResponseForbidden("Insufficient permissions") settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True) @@ -2598,6 +2603,68 @@ class ShareLinkViewSet(ModelViewSet, PassUserMixin): ordering_fields = ("created", "expiration", "document") +class ShareBundleViewSet(ModelViewSet, PassUserMixin): + model = ShareBundle + + queryset = ShareBundle.objects.all() + + serializer_class = ShareBundleSerializer + pagination_class = StandardPagination + permission_classes = (IsAuthenticated, PaperlessObjectPermissions) + filter_backends = ( + DjangoFilterBackend, + OrderingFilter, + ObjectOwnedOrGrantedPermissionsFilter, + ) + filterset_class = ShareBundleFilterSet + ordering_fields = ("created", "expiration", "status") + + def get_queryset(self): + return super().get_queryset().prefetch_related("documents") + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + document_ids = serializer.validated_data["document_ids"] + documents_qs = Document.objects.filter(pk__in=document_ids).select_related( + "owner", + ) + found_ids = set(documents_qs.values_list("pk", flat=True)) + missing = sorted(set(document_ids) - found_ids) + if missing: + raise ValidationError( + { + "document_ids": _( + "Documents not found: %(ids)s", + ) + % {"ids": ", ".join(str(item) for item in missing)}, + }, + ) + + documents = list(documents_qs) + for document in documents: + if not has_perms_owner_aware(request.user, "view_document", document): + raise ValidationError( + { + "document_ids": _( + "Insufficient permissions to share document %(id)s.", + ) + % {"id": document.pk}, + }, + ) + + serializer.save( + owner=request.user, + documents=documents, + ) + headers = self.get_success_headers(serializer.data) + return Response( + serializer.data, + status=status.HTTP_201_CREATED, + headers=headers, + ) + + class SharedLinkView(View): authentication_classes = [] permission_classes = [] diff --git a/src/paperless/urls.py b/src/paperless/urls.py index e24d1a459..505eb3fa0 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -29,6 +29,7 @@ from documents.views import RemoteVersionView from documents.views import SavedViewViewSet from documents.views import SearchAutoCompleteView from documents.views import SelectionDataView +from documents.views import ShareBundleViewSet from documents.views import SharedLinkView from documents.views import ShareLinkViewSet from documents.views import StatisticsView @@ -72,6 +73,7 @@ api_router.register(r"users", UserViewSet, basename="users") api_router.register(r"groups", GroupViewSet, basename="groups") api_router.register(r"mail_accounts", MailAccountViewSet) api_router.register(r"mail_rules", MailRuleViewSet) +api_router.register(r"share_bundles", ShareBundleViewSet) api_router.register(r"share_links", ShareLinkViewSet) api_router.register(r"workflow_triggers", WorkflowTriggerViewSet) api_router.register(r"workflow_actions", WorkflowActionViewSet)