mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Merge pull request #2827 from paperless-ngx/feature-owner-aware-unique-model-names
Feature: owner-aware unique model name constraint
This commit is contained in:
commit
2042b85056
@ -0,0 +1,107 @@
|
|||||||
|
# Generated by Django 4.1.5 on 2023-03-04 22:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("documents", "1032_alter_correspondent_matching_algorithm_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="documenttype",
|
||||||
|
options={
|
||||||
|
"ordering": ("name",),
|
||||||
|
"verbose_name": "document type",
|
||||||
|
"verbose_name_plural": "document types",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name="tag",
|
||||||
|
options={
|
||||||
|
"ordering": ("name",),
|
||||||
|
"verbose_name": "tag",
|
||||||
|
"verbose_name_plural": "tags",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="correspondent",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(max_length=128, verbose_name="name"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="documenttype",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(max_length=128, verbose_name="name"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="storagepath",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(max_length=128, verbose_name="name"),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="tag",
|
||||||
|
name="name",
|
||||||
|
field=models.CharField(max_length=128, verbose_name="name"),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="correspondent",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("name", "owner"),
|
||||||
|
name="documents_correspondent_unique_name_owner",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="correspondent",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("owner__isnull", True)),
|
||||||
|
fields=("name",),
|
||||||
|
name="documents_correspondent_name_uniq",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="documenttype",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("name", "owner"),
|
||||||
|
name="documents_documenttype_unique_name_owner",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="documenttype",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("owner__isnull", True)),
|
||||||
|
fields=("name",),
|
||||||
|
name="documents_documenttype_name_uniq",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="storagepath",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("name", "owner"), name="documents_storagepath_unique_name_owner"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="storagepath",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("owner__isnull", True)),
|
||||||
|
fields=("name",),
|
||||||
|
name="documents_storagepath_name_uniq",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="tag",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
fields=("name", "owner"), name="documents_tag_unique_name_owner"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddConstraint(
|
||||||
|
model_name="tag",
|
||||||
|
constraint=models.UniqueConstraint(
|
||||||
|
condition=models.Q(("owner__isnull", True)),
|
||||||
|
fields=("name",),
|
||||||
|
name="documents_tag_name_uniq",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -23,7 +23,20 @@ ALL_STATES = sorted(states.ALL_STATES)
|
|||||||
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
|
TASK_STATE_CHOICES = sorted(zip(ALL_STATES, ALL_STATES))
|
||||||
|
|
||||||
|
|
||||||
class MatchingModel(models.Model):
|
class ModelWithOwner(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
verbose_name=_("owner"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
abstract = True
|
||||||
|
|
||||||
|
|
||||||
|
class MatchingModel(ModelWithOwner):
|
||||||
|
|
||||||
MATCH_NONE = 0
|
MATCH_NONE = 0
|
||||||
MATCH_ANY = 1
|
MATCH_ANY = 1
|
||||||
@ -43,7 +56,7 @@ class MatchingModel(models.Model):
|
|||||||
(MATCH_AUTO, _("Automatic")),
|
(MATCH_AUTO, _("Automatic")),
|
||||||
)
|
)
|
||||||
|
|
||||||
name = models.CharField(_("name"), max_length=128, unique=True)
|
name = models.CharField(_("name"), max_length=128)
|
||||||
|
|
||||||
match = models.CharField(_("match"), max_length=256, blank=True)
|
match = models.CharField(_("match"), max_length=256, blank=True)
|
||||||
|
|
||||||
@ -58,32 +71,29 @@ class MatchingModel(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
ordering = ("name",)
|
ordering = ("name",)
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["name", "owner"],
|
||||||
|
name="%(app_label)s_%(class)s_unique_name_owner",
|
||||||
|
),
|
||||||
|
models.UniqueConstraint(
|
||||||
|
name="%(app_label)s_%(class)s_name_uniq",
|
||||||
|
fields=["name"],
|
||||||
|
condition=models.Q(owner__isnull=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
class ModelWithOwner(models.Model):
|
class Correspondent(MatchingModel):
|
||||||
owner = models.ForeignKey(
|
class Meta(MatchingModel.Meta):
|
||||||
User,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
on_delete=models.SET_NULL,
|
|
||||||
verbose_name=_("owner"),
|
|
||||||
)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
abstract = True
|
|
||||||
|
|
||||||
|
|
||||||
class Correspondent(MatchingModel, ModelWithOwner):
|
|
||||||
class Meta:
|
|
||||||
ordering = ("name",)
|
|
||||||
verbose_name = _("correspondent")
|
verbose_name = _("correspondent")
|
||||||
verbose_name_plural = _("correspondents")
|
verbose_name_plural = _("correspondents")
|
||||||
|
|
||||||
|
|
||||||
class Tag(MatchingModel, ModelWithOwner):
|
class Tag(MatchingModel):
|
||||||
|
|
||||||
color = models.CharField(_("color"), max_length=7, default="#a6cee3")
|
color = models.CharField(_("color"), max_length=7, default="#a6cee3")
|
||||||
|
|
||||||
@ -96,25 +106,24 @@ class Tag(MatchingModel, ModelWithOwner):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta(MatchingModel.Meta):
|
||||||
verbose_name = _("tag")
|
verbose_name = _("tag")
|
||||||
verbose_name_plural = _("tags")
|
verbose_name_plural = _("tags")
|
||||||
|
|
||||||
|
|
||||||
class DocumentType(MatchingModel, ModelWithOwner):
|
class DocumentType(MatchingModel):
|
||||||
class Meta:
|
class Meta(MatchingModel.Meta):
|
||||||
verbose_name = _("document type")
|
verbose_name = _("document type")
|
||||||
verbose_name_plural = _("document types")
|
verbose_name_plural = _("document types")
|
||||||
|
|
||||||
|
|
||||||
class StoragePath(MatchingModel, ModelWithOwner):
|
class StoragePath(MatchingModel):
|
||||||
path = models.CharField(
|
path = models.CharField(
|
||||||
_("path"),
|
_("path"),
|
||||||
max_length=512,
|
max_length=512,
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta(MatchingModel.Meta):
|
||||||
ordering = ("name",)
|
|
||||||
verbose_name = _("storage path")
|
verbose_name = _("storage path")
|
||||||
verbose_name_plural = _("storage paths")
|
verbose_name_plural = _("storage paths")
|
||||||
|
|
||||||
|
@ -68,6 +68,25 @@ class MatchingModelSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
slug = SerializerMethodField()
|
slug = SerializerMethodField()
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
# see https://github.com/encode/django-rest-framework/issues/7173
|
||||||
|
name = data["name"] if "name" in data else self.instance.name
|
||||||
|
owner = (
|
||||||
|
data["owner"]
|
||||||
|
if "owner" in data
|
||||||
|
else self.user
|
||||||
|
if hasattr(self, "user")
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if ("name" in data or "owner" in data) and self.Meta.model.objects.filter(
|
||||||
|
name=name,
|
||||||
|
owner=owner,
|
||||||
|
).exists():
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"error": "Object violates owner / name unique constraint"},
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
def validate_match(self, match):
|
def validate_match(self, match):
|
||||||
if (
|
if (
|
||||||
"matching_algorithm" in self.initial_data
|
"matching_algorithm" in self.initial_data
|
||||||
@ -186,6 +205,17 @@ class OwnedObjectSerializer(serializers.ModelSerializer, SetPermissionsMixin):
|
|||||||
def update(self, instance, validated_data):
|
def update(self, instance, validated_data):
|
||||||
if "set_permissions" in validated_data:
|
if "set_permissions" in validated_data:
|
||||||
self._set_permissions(validated_data["set_permissions"], instance)
|
self._set_permissions(validated_data["set_permissions"], instance)
|
||||||
|
if "owner" in validated_data and "name" in self.Meta.fields:
|
||||||
|
name = validated_data["name"] if "name" in validated_data else instance.name
|
||||||
|
not_unique = (
|
||||||
|
self.Meta.model.objects.exclude(pk=instance.pk)
|
||||||
|
.filter(owner=validated_data["owner"], name=name)
|
||||||
|
.exists()
|
||||||
|
)
|
||||||
|
if not_unique:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"error": "Object violates owner / name unique constraint"},
|
||||||
|
)
|
||||||
return super().update(instance, validated_data)
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
|
|
||||||
|
@ -20,6 +20,8 @@ except ImportError:
|
|||||||
import backports.zoneinfo as zoneinfo
|
import backports.zoneinfo as zoneinfo
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django.db import transaction
|
||||||
|
from django.db.utils import IntegrityError
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
@ -1844,6 +1846,100 @@ class TestDocumentApi(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
|
||||||
|
|
||||||
|
def test_tag_unique_name_and_owner(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Multiple users
|
||||||
|
- Tags owned by particular users
|
||||||
|
WHEN:
|
||||||
|
- API request for creating items which are unique by name and owner
|
||||||
|
THEN:
|
||||||
|
- Unique items are created
|
||||||
|
- Non-unique items are not allowed
|
||||||
|
"""
|
||||||
|
user1 = User.objects.create_user(username="test1")
|
||||||
|
user1.user_permissions.add(*Permission.objects.filter(codename="add_tag"))
|
||||||
|
user1.save()
|
||||||
|
|
||||||
|
user2 = User.objects.create_user(username="test2")
|
||||||
|
user2.user_permissions.add(*Permission.objects.filter(codename="add_tag"))
|
||||||
|
user2.save()
|
||||||
|
|
||||||
|
# User 1 creates tag 1 owned by user 1 by default
|
||||||
|
# No issue
|
||||||
|
self.client.force_authenticate(user1)
|
||||||
|
response = self.client.post("/api/tags/", {"name": "tag 1"}, format="json")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
# User 2 creates tag 1 owned by user 2 by default
|
||||||
|
# No issue
|
||||||
|
self.client.force_authenticate(user2)
|
||||||
|
response = self.client.post("/api/tags/", {"name": "tag 1"}, format="json")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
# User 2 creates tag 2 owned by user 1
|
||||||
|
# No issue
|
||||||
|
self.client.force_authenticate(user2)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/tags/",
|
||||||
|
{"name": "tag 2", "owner": user1.pk},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
# User 1 creates tag 2 owned by user 1 by default
|
||||||
|
# Not allowed, would create tag2/user1 which already exists
|
||||||
|
self.client.force_authenticate(user1)
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/tags/",
|
||||||
|
{"name": "tag 2"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
# User 1 creates tag 2 owned by user 1
|
||||||
|
# Not allowed, would create tag2/user1 which already exists
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/tags/",
|
||||||
|
{"name": "tag 2", "owner": user1.pk},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
def test_tag_unique_name_and_owner_enforced_on_update(self):
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Multiple users
|
||||||
|
- Tags owned by particular users
|
||||||
|
WHEN:
|
||||||
|
- API request for to update tag in such as way as makes it non-unqiue
|
||||||
|
THEN:
|
||||||
|
- Unique items are created
|
||||||
|
- Non-unique items are not allowed on update
|
||||||
|
"""
|
||||||
|
user1 = User.objects.create_user(username="test1")
|
||||||
|
user1.user_permissions.add(*Permission.objects.filter(codename="change_tag"))
|
||||||
|
user1.save()
|
||||||
|
|
||||||
|
user2 = User.objects.create_user(username="test2")
|
||||||
|
user2.user_permissions.add(*Permission.objects.filter(codename="change_tag"))
|
||||||
|
user2.save()
|
||||||
|
|
||||||
|
# Create name tag 1 owned by user 1
|
||||||
|
# Create name tag 1 owned by user 2
|
||||||
|
Tag.objects.create(name="tag 1", owner=user1)
|
||||||
|
tag2 = Tag.objects.create(name="tag 1", owner=user2)
|
||||||
|
|
||||||
|
# User 2 attempts to change the owner of tag to user 1
|
||||||
|
# Not allowed, would change to tag1/user1 which already exists
|
||||||
|
self.client.force_authenticate(user2)
|
||||||
|
response = self.client.patch(
|
||||||
|
f"/api/tags/{tag2.id}/",
|
||||||
|
{"owner": user1.pk},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentApiV2(DirectoriesMixin, APITestCase):
|
class TestDocumentApiV2(DirectoriesMixin, APITestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Loading…
x
Reference in New Issue
Block a user