diff --git a/CHANGELOG.md b/CHANGELOG.md index 845783c..c4ef1c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added compatibility with PrimeVue 4 [#16](https://github.com/archesproject/arches-references/pull/16) ### Changed +- Generate URIs when not supplied [#17](https://github.com/archesproject/arches-references/pull/17) ### Fixed diff --git a/arches_references/migrations/0004_require_controlledlistitem_uri.py b/arches_references/migrations/0004_require_controlledlistitem_uri.py new file mode 100644 index 0000000..7f0faa9 --- /dev/null +++ b/arches_references/migrations/0004_require_controlledlistitem_uri.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.14 on 2024-07-30 16:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("arches_references", "0003_add_node_index"), + ] + + operations = [ + migrations.AlterField( + model_name="listitem", + name="uri", + field=models.URLField(blank=True, max_length=2048), + ), + ] diff --git a/arches_references/models.py b/arches_references/models.py index f2a2402..b0d2c2c 100644 --- a/arches_references/models.py +++ b/arches_references/models.py @@ -2,20 +2,21 @@ import uuid from collections import defaultdict +from django.conf import settings from django.core.exceptions import ValidationError from django.core.validators import MinValueValidator -from django.db import models +from django.db import models, transaction from django.db.models import Deferrable, Q from django.utils.translation import gettext_lazy as _ from arches.app.models.models import DValueType, Language, Node +from arches.app.models.utils import field_names from arches_references.querysets import ( ListQuerySet, ListItemImageManager, ListItemValueQuerySet, NodeQuerySet, ) -from arches_references.utils import field_names class List(models.Model): @@ -124,7 +125,7 @@ def bulk_update_item_parentage_and_order(self, parent_map, sortorder_map): class ListItem(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - uri = models.URLField(max_length=2048, null=True, blank=True) + uri = models.URLField(max_length=2048, null=False, blank=True) list = models.ForeignKey( List, on_delete=models.CASCADE, @@ -157,15 +158,32 @@ class Meta: ), ] + def clean_fields(self, exclude=None): + super().clean_fields(exclude=exclude) + if not self.id and "id" not in exclude: + id_field = [f for f in self._meta.fields if f.name == "id"][0] + self.id = id_field.get_default() + def clean(self): + if not self.uri: + self.uri = self.generate_uri() + + def generate_uri(self): + """Similar logic exists in `etl_collections_to_controlled_lists` migration.""" + if not self.id: + raise RuntimeError("URI generation attempted without a primary key.") + + parts = [settings.PUBLIC_SERVER_ADDRESS.rstrip("/")] + if settings.FORCE_SCRIPT_NAME: + parts.append(settings.FORCE_SCRIPT_NAME) + parts += ["plugins", "controlled-list-manager", "item", str(self.id)] + + return "/".join(parts) + + def ensure_pref_label(self): if not self.list_item_values.filter(valuetype="prefLabel").exists(): raise ValidationError(_("At least one preferred label is required.")) - def clean_fields(self, exclude=None): - super().clean_fields(exclude=exclude) - if (not exclude or "uri" not in exclude) and not self.uri: - self.uri = None - def serialize(self, depth_map=None, flat=False): if depth_map is None: depth_map = defaultdict(int) @@ -260,15 +278,10 @@ def serialize(self): } def delete(self): - msg = _("Deleting the item's only remaining preferred label is not permitted.") - if ( - self.valuetype_id == "prefLabel" - and len(self.list_item.list_item_values.filter(valuetype_id="prefLabel")) - < 2 - ): - raise ValidationError(msg) - - return super().delete() + with transaction.atomic(): + ret = super().delete() + self.list_item.ensure_pref_label() + return ret class ListItemImage(models.Model): diff --git a/arches_references/utils.py b/arches_references/utils.py deleted file mode 100644 index a69cdb4..0000000 --- a/arches_references/utils.py +++ /dev/null @@ -1,2 +0,0 @@ -def field_names(instance): - return {f.name for f in instance.__class__._meta.fields} diff --git a/arches_references/views.py b/arches_references/views.py index 5da8367..8a2d0ca 100644 --- a/arches_references/views.py +++ b/arches_references/views.py @@ -9,6 +9,7 @@ from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ +from arches.app.models.utils import field_names from arches.app.utils.betterJSONSerializer import JSONDeserializer from arches.app.utils.decorators import group_required from arches.app.utils.permission_backend import get_nodegroups_by_perm @@ -23,7 +24,6 @@ ListItemValue, NodeProxy, ) -from arches_references.utils import field_names logger = logging.getLogger(__name__) @@ -186,19 +186,26 @@ def post(self, request): return JSONErrorResponse(status=HTTPStatus.BAD_REQUEST) try: - with transaction.atomic(): - controlled_list = ( - List.objects.filter(pk=list_id) - .annotate(max_sortorder=Max("list_items__sortorder", default=-1)) - .get() - ) - item = ListItem.objects.create( - list=controlled_list, - sortorder=controlled_list.max_sortorder + 1, - parent_id=parent_id, - ) + controlled_list = ( + List.objects.filter(pk=list_id) + .annotate(max_sortorder=Max("list_items__sortorder", default=-1)) + .get() + ) except List.DoesNotExist: - return JSONErrorResponse(status=HTTPStatus.NOT_FOUND) + return JSONErrorResponse(status=HTTPStatus.BAD_REQUEST) + + try: + item = ListItem( + list=controlled_list, + sortorder=controlled_list.max_sortorder + 1, + parent_id=parent_id, + ) + item.full_clean() + item.save() + except ValidationError as ve: + return JSONErrorResponse( + message="\n".join(ve.messages), status=HTTPStatus.BAD_REQUEST + ) return JSONResponse(item.serialize(), status=HTTPStatus.CREATED) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..2562dd2 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,19 @@ +from django.test import TestCase + +from arches_references.models import ListItem + +# these tests can be run from the command line via +# python manage.py test tests.test_models --settings="tests.test_settings" + + +class ListItemTests(TestCase): + def test_uri_generation_guards_against_failure(self): + # Don't bother setting up a list. + item = ListItem(sortorder=0) + item.id = None + + with self.assertRaises(RuntimeError): + item.clean() + + item.full_clean(exclude={"list"}) + self.assertIsNotNone(item.uri) diff --git a/tests/test_views.py b/tests/test_views.py index a3783f8..0919cbc 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -5,6 +5,7 @@ from django.contrib.auth.models import Group, User from django.test import TestCase +from django.test.utils import override_settings from django.urls import reverse from guardian.shortcuts import assign_perm @@ -391,7 +392,8 @@ def test_recursive_cycles(self): ) self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST, response.content) - def test_update_uri_blank(self): + @override_settings(PUBLIC_SERVER_ADDRESS="public/", FORCE_SCRIPT_NAME="script") + def test_generate_uri(self): self.client.force_login(self.admin) item = self.list1.list_items.first() @@ -402,7 +404,9 @@ def test_update_uri_blank(self): ) self.assertEqual(response.status_code, HTTPStatus.NO_CONTENT, response.content) item.refresh_from_db() - self.assertIsNone(item.uri) + self.assertEqual( + item.uri, f"public/script/plugins/controlled-list-manager/item/{item.pk}" + ) def test_delete_list_item(self): self.client.force_login(self.admin)