Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/core/jinja2/core/artwork_detail.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% extends '/core/base.jinja2' %}
{% block content %}
<div class="Container">
<h1>{{ artwork.title }}</h1>
<p>{{ _("Author") }}: {{ artwork.author }}</p>
{% if artwork.marker %}
<p>{{ _("Marker") }}: <a href="{{ artwork.marker.get_absolute_url() }}">{{ artwork.marker.title }}</a></p>
{% endif %}
{% if artwork.augmented %}
<p>{{ _("Object") }}: <a href="{{ artwork.augmented.get_absolute_url() }}">{{ artwork.augmented.title }}</a></p>
{% endif %}
<p>{{ _("Uploaded") }}: {{ artwork.uploaded_at }}</p>
</div>
{% endblock %}
11 changes: 11 additions & 0 deletions src/core/jinja2/core/marker_detail.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{% extends '/core/base.jinja2' %}
{% block content %}
<div class="Container">
<h1>{{ marker.title }}</h1>
<p>{{ _("Author") }}: {{ marker.author }}</p>
{% if marker.source %}
<img src="/media/public/{{ marker.source.name }}" alt="{{ marker.title }}" style="max-width: 100%;">
{% endif %}
<p>{{ _("Uploaded") }}: {{ marker.uploaded_at }}</p>
</div>
{% endblock %}
14 changes: 14 additions & 0 deletions src/core/jinja2/core/object_detail.jinja2
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{% extends '/core/base.jinja2' %}
{% block content %}
<div class="Container">
<h1>{{ object.title }}</h1>
<p>{{ _("Author") }}: {{ object.author }}</p>
{% if object.source %}
<video src="/media/public/{{ object.source.name }}" controls style="max-width: 100%;"></video>
{% endif %}
{% if object.thumbnail %}
<img src="{{ object.thumbnail.url }}" alt="{{ object.title }}" style="max-width: 100%;">
{% endif %}
<p>{{ _("Uploaded") }}: {{ object.uploaded_at }}</p>
</div>
{% endblock %}
29 changes: 29 additions & 0 deletions src/core/migrations/0045_add_slug_to_marker_object_artwork.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
141 changes: 141 additions & 0 deletions src/core/migrations/0046_remove_artwork_insert_insert_and_more.py
Original file line number Diff line number Diff line change
@@ -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",
),
),
),
]
46 changes: 46 additions & 0 deletions src/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)

Comment on lines +489 to +497

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of using this counter in a while. If it conflicts with an existing slug, use directly the ID which is unique, not a counter in a while

def get_absolute_url(self):
return reverse("artwork-detail", kwargs={"slug": self.slug})

@property
def exhibits_count(self):
Expand Down Expand Up @@ -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()
Expand Down
45 changes: 45 additions & 0 deletions src/core/tests/test_url_resolution_order.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Guard the ordering of the <slug:slug> 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 <slug:slug> 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 "
"<slug:slug> 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
10 changes: 10 additions & 0 deletions src/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
)
from core.views.views import (
ar_view,
artwork_detail,
artwork_preview,
collection,
convert_gif_to_spritesheet,
Expand All @@ -29,8 +30,10 @@
exhibit_detail,
exhibit_select,
get_element,
marker_detail,
marker_preview,
marker_upload,
object_detail,
object_upload,
related_content,
see_all,
Expand All @@ -41,8 +44,13 @@
path("", home, name="home"),
path("ar/", ar_view, name="ar-view"),
path("artwork/", artwork_preview, name="artwork-preview"),
# Each <slug:slug> 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/<slug:slug>/", artwork_detail, name="artwork-detail"),
path("collection/", collection, name="collection"),
path("community/", community, name="community"),
path("content/delete/", delete, name="delete-content"),
Expand All @@ -56,13 +64,15 @@
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/<slug:slug>/", marker_detail, name="marker-detail"),
path("objects/edit/", edit_object, name="edit-object"),
path("objects/upload/", object_upload, name="object-upload"),
path(
"objects/convert-spritesheet/",
convert_gif_to_spritesheet,
name="convert-spritesheet",
),
path("objects/<slug:slug>/", 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"),
Expand Down
Loading
Loading