From 4ff2cb9c724daa9895388ab0ea8c5e05fb6d1379 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:11:28 -0700 Subject: [PATCH] Make them owned, duh Update 0028_ocrtemplate_ocrtemplatezone.py --- .../0028_ocrtemplate_ocrtemplatezone.py | 12 +++ src/documents/models.py | 2 +- src/documents/serialisers.py | 13 +-- src/documents/tests/test_api_ocr_templates.py | 92 +++++++++++++++++++ src/documents/views.py | 13 ++- 5 files changed, 122 insertions(+), 10 deletions(-) diff --git a/src/documents/migrations/0028_ocrtemplate_ocrtemplatezone.py b/src/documents/migrations/0028_ocrtemplate_ocrtemplatezone.py index d93ddecf6..a7235e2c3 100644 --- a/src/documents/migrations/0028_ocrtemplate_ocrtemplatezone.py +++ b/src/documents/migrations/0028_ocrtemplate_ocrtemplatezone.py @@ -3,6 +3,7 @@ import django.core.validators import django.db.models.deletion import django.utils.timezone +from django.conf import settings from django.db import migrations from django.db import models @@ -26,6 +27,17 @@ class Migration(migrations.Migration): ), ), ("name", models.CharField(max_length=128, verbose_name="name")), + ( + "owner", + models.ForeignKey( + blank=True, + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + verbose_name="owner", + ), + ), ( "source_width", models.PositiveIntegerField( diff --git a/src/documents/models.py b/src/documents/models.py index 39070cd7f..51808f968 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -2151,7 +2151,7 @@ class WorkflowRun(SoftDeleteModel): return f"WorkflowRun of {self.workflow} at {self.run_at} on {self.document}" -class OcrTemplate(models.Model): +class OcrTemplate(ModelWithOwner): """ Defines a set of OCR extraction zones for a specific document type. diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 70ca2d9b1..56695fbf8 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -3781,7 +3781,7 @@ class OcrTemplateZoneSerializer(serializers.ModelSerializer): return value -class OcrTemplateSerializer(serializers.ModelSerializer): +class OcrTemplateSerializer(OwnedObjectSerializer): zones = OcrTemplateZoneSerializer(many=True, required=False) class Meta: @@ -3798,6 +3798,10 @@ class OcrTemplateSerializer(serializers.ModelSerializer): "created", "updated", "zones", + "owner", + "permissions", + "user_can_change", + "set_permissions", ] read_only_fields = ["created", "updated"] @@ -3842,17 +3846,14 @@ class OcrTemplateSerializer(serializers.ModelSerializer): def create(self, validated_data): zones_data = validated_data.pop("zones", []) - template = OcrTemplate.objects.create(**validated_data) + template = super().create(validated_data) for zone_data in zones_data: OcrTemplateZone.objects.create(template=template, **zone_data) return template def update(self, instance, validated_data): zones_data = validated_data.pop("zones", None) - - for attr, value in validated_data.items(): - setattr(instance, attr, value) - instance.save() + instance = super().update(instance, validated_data) if zones_data is not None: # Replace all zones with the new set diff --git a/src/documents/tests/test_api_ocr_templates.py b/src/documents/tests/test_api_ocr_templates.py index ec41bb6b8..826ed2e08 100644 --- a/src/documents/tests/test_api_ocr_templates.py +++ b/src/documents/tests/test_api_ocr_templates.py @@ -109,6 +109,98 @@ class TestOcrTemplatesAPI(DirectoriesMixin, APITestCase): self.assertEqual(OcrTemplate.objects.count(), 1) self.assertEqual(OcrTemplateZone.objects.count(), 1) + def test_create_template_sets_owner(self): + """ + GIVEN: + - A user creating an OCR template + WHEN: + - API request to create it without an owner + THEN: + - The requesting user owns the template + """ + response = self.client.post( + self.ENDPOINT, + json.dumps(self._make_template_data()), + content_type="application/json", + ) + + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(OcrTemplate.objects.get().owner, self.user) + + def test_list_only_shows_permitted_templates(self): + """ + GIVEN: + - Templates that are unowned, owned by the user, owned by another + user, and owned by another user but shared with the user + WHEN: + - The user lists OCR templates + THEN: + - Only the unowned, owned and shared templates are returned + """ + other = User.objects.create_user(username="other") + user = User.objects.create_user(username="template_viewer") + user.user_permissions.add(Permission.objects.get(codename="view_ocrtemplate")) + + def template(name, owner=None): + return OcrTemplate.objects.create( + name=name, + document_type=self.doc_type, + source_width=1000, + source_height=1000, + owner=owner, + ) + + template("Unowned") + template("Mine", owner=user) + template("Private", owner=other) + grant_object(user, template("Shared", owner=other), "view_ocrtemplate") + self.client.force_authenticate(user=user) + + response = self.client.get(self.ENDPOINT) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertCountEqual( + [t["name"] for t in response.json()["results"]], + ["Unowned", "Mine", "Shared"], + ) + + def test_cannot_change_another_users_template(self): + """ + GIVEN: + - A template owned by another user + WHEN: + - A user with global change permission tries to update it + THEN: + - The update is refused until the user is granted change on it + """ + other = User.objects.create_user(username="other") + user = User.objects.create_user(username="template_editor") + user.user_permissions.add( + *Permission.objects.filter( + codename__in=["view_ocrtemplate", "change_ocrtemplate"], + ), + ) + template = OcrTemplate.objects.create( + name="Private", + document_type=self.doc_type, + source_width=1000, + source_height=1000, + owner=other, + ) + grant_object(user, template, "view_ocrtemplate") + self.client.force_authenticate(user=user) + url = f"{self.ENDPOINT}{template.pk}/" + + response = self.client.patch(url, {"name": "Renamed"}, format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + grant_object(user, template, "change_ocrtemplate") + response = self.client.patch(url, {"name": "Renamed"}, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + template.refresh_from_db() + self.assertEqual(template.name, "Renamed") + self.assertEqual(template.owner, other) + def test_create_template_multiple_zones(self): """ GIVEN: diff --git a/src/documents/views.py b/src/documents/views.py index 3966300b1..fb03aa8bc 100644 --- a/src/documents/views.py +++ b/src/documents/views.py @@ -5835,11 +5835,13 @@ def serve_logo(request: HttpRequest, filename: str | None = None) -> FileRespons ) -class OcrTemplateViewSet(ModelViewSet): +class OcrTemplateViewSet(BulkPermissionMixin, PassUserMixin, ModelViewSet[OcrTemplate]): """CRUD for OCR templates with zone definitions.""" + model = OcrTemplate + queryset = ( - OcrTemplate.objects.all() + OcrTemplate.objects.select_related("owner") .prefetch_related( "zones", "zones__custom_field", @@ -5847,8 +5849,13 @@ class OcrTemplateViewSet(ModelViewSet): .order_by("name") ) serializer_class = OcrTemplateSerializer - permission_classes = (IsAuthenticated, PaperlessObjectPermissions) pagination_class = StandardPagination + permission_classes = (IsAuthenticated, PaperlessObjectPermissions) + filter_backends = ( + OrderingFilter, + PermittedObjectsFilter, + ) + ordering_fields = ("name",) @action( detail=False,