diff --git a/src/core/jinja2/core/artwork_detail.jinja2 b/src/core/jinja2/core/artwork_detail.jinja2 new file mode 100644 index 00000000..d4b27ebc --- /dev/null +++ b/src/core/jinja2/core/artwork_detail.jinja2 @@ -0,0 +1,14 @@ +{% extends '/core/base.jinja2' %} +{% block content %} +
+

{{ artwork.title }}

+

{{ _("Author") }}: {{ artwork.author }}

+ {% if artwork.marker %} +

{{ _("Marker") }}: {{ artwork.marker.title }}

+ {% endif %} + {% if artwork.augmented %} +

{{ _("Object") }}: {{ artwork.augmented.title }}

+ {% endif %} +

{{ _("Uploaded") }}: {{ artwork.uploaded_at }}

+
+{% endblock %} diff --git a/src/core/jinja2/core/marker_detail.jinja2 b/src/core/jinja2/core/marker_detail.jinja2 new file mode 100644 index 00000000..bc37cf51 --- /dev/null +++ b/src/core/jinja2/core/marker_detail.jinja2 @@ -0,0 +1,11 @@ +{% extends '/core/base.jinja2' %} +{% block content %} +
+

{{ marker.title }}

+

{{ _("Author") }}: {{ marker.author }}

+ {% if marker.source %} + {{ marker.title }} + {% endif %} +

{{ _("Uploaded") }}: {{ marker.uploaded_at }}

+
+{% endblock %} diff --git a/src/core/jinja2/core/object_detail.jinja2 b/src/core/jinja2/core/object_detail.jinja2 new file mode 100644 index 00000000..0abc83da --- /dev/null +++ b/src/core/jinja2/core/object_detail.jinja2 @@ -0,0 +1,14 @@ +{% extends '/core/base.jinja2' %} +{% block content %} +
+

{{ object.title }}

+

{{ _("Author") }}: {{ object.author }}

+ {% if object.source %} + + {% endif %} + {% if object.thumbnail %} + {{ object.title }} + {% endif %} +

{{ _("Uploaded") }}: {{ object.uploaded_at }}

+
+{% endblock %} diff --git a/src/core/migrations/0045_add_slug_to_marker_object_artwork.py b/src/core/migrations/0045_add_slug_to_marker_object_artwork.py new file mode 100644 index 00000000..4dc4f8df --- /dev/null +++ b/src/core/migrations/0045_add_slug_to_marker_object_artwork.py @@ -0,0 +1,29 @@ +# Renumbered from 0028 when develop was merged in: two 0028_* migrations +# would leave the graph with conflicting leaf nodes. + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0044_populate_sound_in_use_fields"), + ] + + operations = [ + migrations.AddField( + model_name='marker', + name='slug', + field=models.SlugField(blank=True, max_length=80, unique=True), + ), + migrations.AddField( + model_name='object', + name='slug', + field=models.SlugField(blank=True, max_length=80, unique=True), + ), + migrations.AddField( + model_name='artwork', + name='slug', + field=models.SlugField(blank=True, max_length=80, unique=True), + ), + ] diff --git a/src/core/migrations/0046_remove_artwork_insert_insert_and_more.py b/src/core/migrations/0046_remove_artwork_insert_insert_and_more.py new file mode 100644 index 00000000..98990b83 --- /dev/null +++ b/src/core/migrations/0046_remove_artwork_insert_insert_and_more.py @@ -0,0 +1,141 @@ +# Generated by Django 6.1.1 on 2026-09-17 18:12 + +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0045_add_slug_to_marker_object_artwork"), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name="artwork", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="artwork", + name="update_update", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="marker", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="marker", + name="update_update", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="object", + name="insert_insert", + ), + pgtrigger.migrations.RemoveTrigger( + model_name="object", + name="update_update", + ), + migrations.AddField( + model_name="artworkevent", + name="slug", + field=models.SlugField(blank=True, db_index=False, max_length=80), + ), + migrations.AddField( + model_name="markerevent", + name="slug", + field=models.SlugField(blank=True, db_index=False, max_length=80), + ), + migrations.AddField( + model_name="objectevent", + name="slug", + field=models.SlugField(blank=True, db_index=False, max_length=80), + ), + pgtrigger.migrations.AddTrigger( + model_name="artwork", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "core_artworkevent" ("augmented_id", "author_id", "created", "description", "id", "marker_id", "modified", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "position_x", "position_y", "scale_x", "scale_y", "slug", "sound_id", "title") VALUES (NEW."augmented_id", NEW."author_id", NEW."created", NEW."description", NEW."id", NEW."marker_id", NEW."modified", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."position_x", NEW."position_y", NEW."scale_x", NEW."scale_y", NEW."slug", NEW."sound_id", NEW."title"); RETURN NULL;', + hash="5e63367fae81726ac16a46d352593ae1168af728", + operation="INSERT", + pgid="pgtrigger_insert_insert_65595", + table="core_artwork", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="artwork", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "core_artworkevent" ("augmented_id", "author_id", "created", "description", "id", "marker_id", "modified", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "position_x", "position_y", "scale_x", "scale_y", "slug", "sound_id", "title") VALUES (NEW."augmented_id", NEW."author_id", NEW."created", NEW."description", NEW."id", NEW."marker_id", NEW."modified", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."position_x", NEW."position_y", NEW."scale_x", NEW."scale_y", NEW."slug", NEW."sound_id", NEW."title"); RETURN NULL;', + hash="e9552c93dd966d86637b42bc885edeb99574e593", + operation="UPDATE", + pgid="pgtrigger_update_update_1ac7c", + table="core_artwork", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="marker", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "core_markerevent" ("author", "created", "file_size", "id", "in_use", "is_used_by_other_user", "marker_img", "modified", "owner_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "print_img", "slug", "source", "thumb_img", "title") VALUES (NEW."author", NEW."created", NEW."file_size", NEW."id", NEW."in_use", NEW."is_used_by_other_user", NEW."marker_img", NEW."modified", NEW."owner_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."print_img", NEW."slug", NEW."source", NEW."thumb_img", NEW."title"); RETURN NULL;', + hash="2c58d9fb44016e14cab644eaf1f1f09296c5a5d3", + operation="INSERT", + pgid="pgtrigger_insert_insert_719b2", + table="core_marker", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="marker", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "core_markerevent" ("author", "created", "file_size", "id", "in_use", "is_used_by_other_user", "marker_img", "modified", "owner_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "print_img", "slug", "source", "thumb_img", "title") VALUES (NEW."author", NEW."created", NEW."file_size", NEW."id", NEW."in_use", NEW."is_used_by_other_user", NEW."marker_img", NEW."modified", NEW."owner_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."print_img", NEW."slug", NEW."source", NEW."thumb_img", NEW."title"); RETURN NULL;', + hash="a20e0970e5fa5c2c399575dd7f2cba6e20db9b6e", + operation="UPDATE", + pgid="pgtrigger_update_update_9a032", + table="core_marker", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="object", + trigger=pgtrigger.compiler.Trigger( + name="insert_insert", + sql=pgtrigger.compiler.UpsertTriggerSql( + func='INSERT INTO "core_objectevent" ("audio_description", "author", "created", "file_extension", "file_name_original", "file_size", "height", "id", "in_use", "is_used_by_other_user", "modified", "owner_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "sound_id", "source", "spritesheet_file", "spritesheet_metadata", "thumbnail", "title", "width") VALUES (NEW."audio_description", NEW."author", NEW."created", NEW."file_extension", NEW."file_name_original", NEW."file_size", NEW."height", NEW."id", NEW."in_use", NEW."is_used_by_other_user", NEW."modified", NEW."owner_id", _pgh_attach_context(), NOW(), \'insert\', NEW."id", NEW."slug", NEW."sound_id", NEW."source", NEW."spritesheet_file", NEW."spritesheet_metadata", NEW."thumbnail", NEW."title", NEW."width"); RETURN NULL;', + hash="dd7898ed958ac123fbbe20c2568791dea1f5319a", + operation="INSERT", + pgid="pgtrigger_insert_insert_25a7e", + table="core_object", + when="AFTER", + ), + ), + ), + pgtrigger.migrations.AddTrigger( + model_name="object", + trigger=pgtrigger.compiler.Trigger( + name="update_update", + sql=pgtrigger.compiler.UpsertTriggerSql( + condition="WHEN (OLD.* IS DISTINCT FROM NEW.*)", + func='INSERT INTO "core_objectevent" ("audio_description", "author", "created", "file_extension", "file_name_original", "file_size", "height", "id", "in_use", "is_used_by_other_user", "modified", "owner_id", "pgh_context_id", "pgh_created_at", "pgh_label", "pgh_obj_id", "slug", "sound_id", "source", "spritesheet_file", "spritesheet_metadata", "thumbnail", "title", "width") VALUES (NEW."audio_description", NEW."author", NEW."created", NEW."file_extension", NEW."file_name_original", NEW."file_size", NEW."height", NEW."id", NEW."in_use", NEW."is_used_by_other_user", NEW."modified", NEW."owner_id", _pgh_attach_context(), NOW(), \'update\', NEW."id", NEW."slug", NEW."sound_id", NEW."source", NEW."spritesheet_file", NEW."spritesheet_metadata", NEW."thumbnail", NEW."title", NEW."width"); RETURN NULL;', + hash="29621b17da2a972b7b6334e955fe8c8c37fa1065", + operation="UPDATE", + pgid="pgtrigger_update_update_27852", + table="core_object", + when="AFTER", + ), + ), + ), + ] diff --git a/src/core/models.py b/src/core/models.py index 49628392..662efd92 100644 --- a/src/core/models.py +++ b/src/core/models.py @@ -6,6 +6,7 @@ from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save from django.dispatch import receiver from django.urls import reverse +from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ from django_extensions.db.models import TimeStampedModel from fast_html import a, audio, img, render, video @@ -152,10 +153,22 @@ class Marker(TimeStampedModel, ContentMixin): file_size = models.IntegerField(default=0, blank=True, null=True) in_use = models.BooleanField(default=False) is_used_by_other_user = models.BooleanField(default=False) + slug = models.SlugField(unique=True, blank=True, max_length=80) def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title) or "marker" + slug = base_slug + counter = 1 + while Marker.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug super().save(*args, **kwargs) + def get_absolute_url(self): + return reverse("marker-detail", kwargs={"slug": self.slug}) + def __str__(self): return self.source.name @@ -263,6 +276,21 @@ class Object(TimeStampedModel, ContentMixin): ) width = models.PositiveIntegerField(null=True, blank=True) height = models.PositiveIntegerField(null=True, blank=True) + slug = models.SlugField(unique=True, blank=True, max_length=80) + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title) or "object" + slug = base_slug + counter = 1 + while Object.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("object-detail", kwargs={"slug": self.slug}) def __str__(self): return self.source.name @@ -454,6 +482,21 @@ class Artwork(TimeStampedModel, ContentMixin): scale_y = models.FloatField(default=1.0) position_x = models.FloatField(default=0.0) position_y = models.FloatField(default=0.0) + slug = models.SlugField(unique=True, blank=True, max_length=80) + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.title) or "artwork" + slug = base_slug + counter = 1 + while Artwork.objects.filter(slug=slug).exclude(pk=self.pk).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + + def get_absolute_url(self): + return reverse("artwork-detail", kwargs={"slug": self.slug}) @property def exhibits_count(self): @@ -511,6 +554,9 @@ class Exhibit(TimeStampedModel, ContentMixin, models.Model): def __str__(self): return self.name + def get_absolute_url(self): + return reverse("exhibit", kwargs={"slug": self.slug}) + @property def artworks_count(self): return self.artworks.count() diff --git a/src/core/tests/test_url_resolution_order.py b/src/core/tests/test_url_resolution_order.py new file mode 100644 index 00000000..fbc1bec7 --- /dev/null +++ b/src/core/tests/test_url_resolution_order.py @@ -0,0 +1,45 @@ +"""Guard the ordering of the detail routes. + +They were originally inserted alphabetically, which put them *before* their +literal siblings. Django matches in order and "upload", "edit", "create" and +"convert-spritesheet" all satisfy the slug converter, so every POST to those +endpoints resolved to a GET-only detail view and came back 405. +""" + +from django.test import TestCase +from django.urls import resolve, reverse + +# Each literal route that a sibling pattern could swallow. +LITERAL_ROUTES = [ + ("create-artwork", "create_artwork"), + ("edit-artwork", "edit_artwork"), + ("edit-marker", "edit_marker"), + ("marker-upload", "marker_upload"), + ("edit-object", "edit_object"), + ("object-upload", "object_upload"), + ("convert-spritesheet", "convert_gif_to_spritesheet"), +] + +DETAIL_VIEWS = {"artwork_detail", "marker_detail", "object_detail"} + + +class TestUrlResolutionOrder(TestCase): + def test_literal_routes_are_not_swallowed_by_a_slug_pattern(self): + for route_name, expected_view in LITERAL_ROUTES: + with self.subTest(route=route_name): + match = resolve(reverse(route_name)) + assert match.func.__name__ == expected_view, ( + f"{route_name} resolved to {match.func.__name__}; a " + " pattern is matching before it" + ) + assert match.func.__name__ not in DETAIL_VIEWS + + def test_the_slug_routes_still_resolve_for_a_real_slug(self): + for route_name, expected_view in [ + ("artwork-detail", "artwork_detail"), + ("marker-detail", "marker_detail"), + ("object-detail", "object_detail"), + ]: + with self.subTest(route=route_name): + url = reverse(route_name, kwargs={"slug": "some-real-slug"}) + assert resolve(url).func.__name__ == expected_view diff --git a/src/core/urls.py b/src/core/urls.py index 3fba7519..33724225 100644 --- a/src/core/urls.py +++ b/src/core/urls.py @@ -14,6 +14,7 @@ ) from core.views.views import ( ar_view, + artwork_detail, artwork_preview, collection, convert_gif_to_spritesheet, @@ -29,8 +30,10 @@ exhibit_detail, exhibit_select, get_element, + marker_detail, marker_preview, marker_upload, + object_detail, object_upload, related_content, see_all, @@ -41,8 +44,13 @@ path("", home, name="home"), path("ar/", ar_view, name="ar-view"), path("artwork/", artwork_preview, name="artwork-preview"), + # Each detail route must come LAST within its prefix group. + # "create", "edit", "upload" and "convert-spritesheet" all match the slug + # converter, and the detail views are GET-only, so a catch-all placed + # before any of them turns every POST to that endpoint into a 405. path("artworks/create/", create_artwork, name="create-artwork"), path("artworks/edit/", edit_artwork, name="edit-artwork"), + path("artworks//", artwork_detail, name="artwork-detail"), path("collection/", collection, name="collection"), path("community/", community, name="community"), path("content/delete/", delete, name="delete-content"), @@ -56,6 +64,7 @@ path("marker/", marker_preview, name="marker-preview"), path("markers/edit/", edit_marker, name="edit-marker"), path("markers/upload/", marker_upload, name="marker-upload"), + path("markers//", marker_detail, name="marker-detail"), path("objects/edit/", edit_object, name="edit-object"), path("objects/upload/", object_upload, name="object-upload"), path( @@ -63,6 +72,7 @@ convert_gif_to_spritesheet, name="convert-spritesheet", ), + path("objects//", object_detail, name="object-detail"), path("sounds/edit/", edit_sound, name="edit-sound"), path("sounds/upload/", sound_upload, name="sound-upload"), path("elements/", get_element, name="get-element"), diff --git a/src/core/views/views.py b/src/core/views/views.py index 9abe83ed..af639239 100644 --- a/src/core/views/views.py +++ b/src/core/views/views.py @@ -543,6 +543,27 @@ def artwork_preview(request): return render(request, "core/ar.jinja2", ctx) +@require_http_methods(["GET"]) +def marker_detail(request, slug): + marker = get_object_or_404(Marker, slug=slug) + ctx = {"marker": marker} + return render(request, "core/marker_detail.jinja2", ctx) + + +@require_http_methods(["GET"]) +def object_detail(request, slug): + obj = get_object_or_404(Object, slug=slug) + ctx = {"object": obj} + return render(request, "core/object_detail.jinja2", ctx) + + +@require_http_methods(["GET"]) +def artwork_detail(request, slug): + artwork = get_object_or_404(Artwork, slug=slug) + ctx = {"artwork": artwork} + return render(request, "core/artwork_detail.jinja2", ctx) + + @login_required def get_element(request): if request.htmx: