Backend initial stuff

This commit is contained in:
shamoon 2025-11-04 10:59:02 -08:00
parent cf3647d6ff
commit 1ff6857a60
No known key found for this signature in database
8 changed files with 467 additions and 1 deletions

View File

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

View File

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

View File

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

View File

@ -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,
),
]

View File

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

View File

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

View File

@ -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 = []

View File

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