From 838a53ace90d007ed8ec2215ca8f77e55170b07e Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Mon, 22 Jun 2026 16:03:27 -0700 Subject: [PATCH] Add Project.display_short_name and guard short_name slug uniqueness (#1156) Publication/talk/video cards showed the project's full name in the flask "View project" chip, which is long and noisy. Add an optional display_short_name field (e.g. "Sidewalk" for "Project Sidewalk") and render it on those three compact snippets via a new get_display_short_name() that falls back to the full name when blank. aria-labels keep the full name. Also clarify the help_text on all three name fields so the admin distinguishes them: name (full title), display_short_name (optional short card label), and short_name (lowercase, no-spaces URL slug). While here, add a Project.clean() that rejects a short_name colliding case-insensitively with an existing project's slug. The project view resolves /projects// via short_name__iexact, so a duplicate slug raises MultipleObjectsReturned (a 500) on both pages. This is the form-level guard; a DB-level unique constraint is deferred until prod data is de-duped. Tests: fallback behavior for get_display_short_name and slug-uniqueness validation (case-insensitive, self-excluded on edit). Co-Authored-By: Claude Opus 4.8 (1M context) --- website/admin/project_admin.py | 2 +- website/models/project.py | 49 +++++++++++++- .../snippets/display_pub_snippet.html | 4 +- .../snippets/display_talk_snippet.html | 2 +- .../snippets/display_video_snippet.html | 2 +- website/tests/test_project.py | 64 +++++++++++++++++++ 6 files changed, 117 insertions(+), 6 deletions(-) diff --git a/website/admin/project_admin.py b/website/admin/project_admin.py index 4884d256..3777f39f 100644 --- a/website/admin/project_admin.py +++ b/website/admin/project_admin.py @@ -92,7 +92,7 @@ class ProjectAdmin(ImageCroppingMixin, admin.ModelAdmin): actions = ('make_public', 'make_private') fieldsets = [ - (None, {'fields': ['name', 'short_name', 'is_visible']}), + (None, {'fields': ['name', 'display_short_name', 'short_name', 'is_visible']}), ('About', {'fields': ['start_date', 'end_date', 'summary', 'about', 'gallery_image', 'cropping', 'thumbnail_alt_text']}), ('Links', {'fields': ['website', 'data_url', 'featured_video', 'featured_code_repo_url']}), ('Associations', {'fields': ['project_umbrellas', 'keywords']}), diff --git a/website/models/project.py b/website/models/project.py index cf0b8ba6..e9505f1c 100644 --- a/website/models/project.py +++ b/website/models/project.py @@ -2,6 +2,7 @@ from django.db.models import Max, Min from django.db.models import F, ExpressionWrapper, fields, Sum, Q, Value from django.db.models.functions import Coalesce +from django.core.exceptions import ValidationError from image_cropping import ImageRatioField from website.utils.upload_validators import validate_image_upload @@ -37,10 +38,21 @@ def get_thumbnail_size_as_str(): return f"{PROJECT_THUMBNAIL_SIZE[0]}x{PROJECT_THUMBNAIL_SIZE[1]}" name = models.CharField(max_length=255) + name.help_text = ("Full project name, shown as the title on the project page and as the " + "heading on cards (e.g., \"Project Sidewalk\").") + + # Optional short label for compact UI (publication/talk/video cards). Falls + # back to `name` via get_display_short_name() when left blank (#1156). This is + # a *display* name, distinct from `short_name` (the URL slug) below. + display_short_name = models.CharField(max_length=255, blank=True, null=True) + display_short_name.help_text = ("Optional short label shown in compact places like publication, " + "talk, and video cards (e.g., \"Sidewalk\"). Leave blank to use " + "the full name.") # Short name is used for urls, and should be name.lower().replace(" ", "") short_name = models.CharField(max_length=255) - short_name.help_text = "This should be the same as name but lower case with no spaces. It is used in the url of the project" + short_name.help_text = ("URL slug only — lowercase, no spaces (e.g., \"projectsidewalk\"). " + "Used in the project's web address, not shown to readers.") # is_visible is the single source of truth for whether a project appears # publicly (gallery, landing page, member pages, and as links from @@ -105,6 +117,32 @@ def get_thumbnail_size_as_str(): updated = models.DateField(auto_now=True) + def clean(self): + """ + Validate that short_name (the URL slug) is unique case-insensitively. + + The project view resolves /projects// with + ``short_name__iexact`` (see views/project.py), so two projects sharing a + slug — even differing only in case — make get_object_or_404 raise + MultipleObjectsReturned, i.e. a 500 on *both* project pages. There is no + DB-level unique constraint yet (existing data must be de-duped first), so + enforce it at the form layer here; the admin runs full_clean() and will + surface this as a field error (#1156). + """ + super().clean() + if self.short_name: + clash = Project.objects.filter(short_name__iexact=self.short_name) + if self.pk: + clash = clash.exclude(pk=self.pk) + if clash.exists(): + raise ValidationError({ + 'short_name': ( + f'A project with the slug "{self.short_name}" already exists. ' + f'Slugs are compared case-insensitively because they are used ' + f'in project URLs. Please choose a different short name.' + ) + }) + def save(self, *args, **kwargs): """ This method overrides the default save method for the Project model. @@ -631,5 +669,14 @@ def get_project_dates_str(self): return f"{self.start_date.year}–{self.end_date.year}" + def get_display_short_name(self): + """ + Returns the short display label for compact UI (publication, talk, and + video cards). Falls back to the full `name` when `display_short_name` is + blank or unset (#1156). Note this is distinct from `short_name`, which is + the lowercase, no-spaces URL slug. + """ + return self.display_short_name or self.name + def __str__(self): return self.name \ No newline at end of file diff --git a/website/templates/snippets/display_pub_snippet.html b/website/templates/snippets/display_pub_snippet.html index 142186c1..de8c8b32 100644 --- a/website/templates/snippets/display_pub_snippet.html +++ b/website/templates/snippets/display_pub_snippet.html @@ -125,7 +125,7 @@

{{ pub.title }}

- {{ project.name }} + {{ project.get_display_short_name }} {% endif %} {% endfor %} @@ -255,7 +255,7 @@

{{ pub.title }}

{% for project in pub.projects.all %} {% if project.can_show_online %} - {{ project.name }} + {{ project.get_display_short_name }} {% endif %} {% endfor %} diff --git a/website/templates/snippets/display_talk_snippet.html b/website/templates/snippets/display_talk_snippet.html index 2b95dc17..09ca090b 100644 --- a/website/templates/snippets/display_talk_snippet.html +++ b/website/templates/snippets/display_talk_snippet.html @@ -123,7 +123,7 @@

- {{ project.name }} + {{ project.get_display_short_name }} {% endif %} {% endfor %} diff --git a/website/templates/snippets/display_video_snippet.html b/website/templates/snippets/display_video_snippet.html index 85236be1..62e55899 100644 --- a/website/templates/snippets/display_video_snippet.html +++ b/website/templates/snippets/display_video_snippet.html @@ -82,7 +82,7 @@

- {{ project.name }} + {{ project.get_display_short_name }} {% endif %} {% endfor %} diff --git a/website/tests/test_project.py b/website/tests/test_project.py index dc092337..5e209fc5 100644 --- a/website/tests/test_project.py +++ b/website/tests/test_project.py @@ -3,12 +3,76 @@ from datetime import date, timedelta from unittest.mock import MagicMock +from django.core.exceptions import ValidationError from django.test import SimpleTestCase from website.models.project import Project from website.tests.base import DatabaseTestCase +# --- Project display short name (#1156) ------------------------------------ + + +class ProjectDisplayShortNameTests(SimpleTestCase): + """ + Tests for Project.get_display_short_name, the short label shown on compact + publication/talk/video cards (#1156). It returns `display_short_name` when + set and falls back to the full `name` when blank or unset. (`short_name` is + the URL slug and is intentionally not used here.) + """ + + def _display(self, name, display_short_name): + obj = MagicMock() + obj.name = name + obj.display_short_name = display_short_name + return Project.get_display_short_name(obj) + + def test_returns_display_short_name_when_set(self): + self.assertEqual(self._display("Project Sidewalk", "Sidewalk"), "Sidewalk") + + def test_falls_back_to_name_when_none(self): + self.assertEqual(self._display("Project Sidewalk", None), "Project Sidewalk") + + def test_falls_back_to_name_when_empty(self): + self.assertEqual(self._display("Project Sidewalk", ""), "Project Sidewalk") + + +# --- Project short_name (slug) uniqueness (#1156) -------------------------- + + +class ProjectShortNameUniquenessTests(DatabaseTestCase): + """ + Project.clean() rejects a short_name that collides (case-insensitively) with + an existing project's slug. The view resolves /projects// via + short_name__iexact, so a duplicate slug would 500 (MultipleObjectsReturned) + both project pages. + """ + + def test_duplicate_slug_rejected(self): + self.make_project(name="Project Sidewalk", short_name="projectsidewalk") + dupe = Project(name="Sidewalk Redux", short_name="projectsidewalk") + with self.assertRaises(ValidationError) as ctx: + dupe.full_clean() + self.assertIn("short_name", ctx.exception.message_dict) + + def test_duplicate_slug_rejected_case_insensitively(self): + self.make_project(name="Project Sidewalk", short_name="projectsidewalk") + dupe = Project(name="Sidewalk Redux", short_name="ProjectSidewalk") + with self.assertRaises(ValidationError) as ctx: + dupe.full_clean() + self.assertIn("short_name", ctx.exception.message_dict) + + def test_unique_slug_allowed(self): + self.make_project(name="Project Sidewalk", short_name="projectsidewalk") + ok = Project(name="Project Aware", short_name="projectaware") + ok.full_clean() # should not raise + + def test_editing_existing_project_keeps_its_own_slug(self): + proj = self.make_project(name="Project Sidewalk", short_name="projectsidewalk") + proj.summary = "Updated summary" + proj.full_clean() # its own slug must not count as a clash + + # --- Project date-range string --------------------------------------------