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 %}
+

+ {% 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 %}
+

+ {% 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: