Skip to content

Commit d4a90bc

Browse files
authored
Merge pull request #1382 from makeabilitylab/1156-display-short-name
Add optional Project.display_short_name for compact artifact cards (#1156)
2 parents f66f10a + 838a53a commit d4a90bc

6 files changed

Lines changed: 117 additions & 6 deletions

File tree

website/admin/project_admin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ class ProjectAdmin(ImageCroppingMixin, admin.ModelAdmin):
9292
actions = ('make_public', 'make_private')
9393

9494
fieldsets = [
95-
(None, {'fields': ['name', 'short_name', 'is_visible']}),
95+
(None, {'fields': ['name', 'display_short_name', 'short_name', 'is_visible']}),
9696
('About', {'fields': ['start_date', 'end_date', 'summary', 'about', 'gallery_image', 'cropping', 'thumbnail_alt_text']}),
9797
('Links', {'fields': ['website', 'data_url', 'featured_video', 'featured_code_repo_url']}),
9898
('Associations', {'fields': ['project_umbrellas', 'keywords']}),

website/models/project.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from django.db.models import Max, Min
33
from django.db.models import F, ExpressionWrapper, fields, Sum, Q, Value
44
from django.db.models.functions import Coalesce
5+
from django.core.exceptions import ValidationError
56

67
from image_cropping import ImageRatioField
78
from website.utils.upload_validators import validate_image_upload
@@ -37,10 +38,21 @@ def get_thumbnail_size_as_str():
3738
return f"{PROJECT_THUMBNAIL_SIZE[0]}x{PROJECT_THUMBNAIL_SIZE[1]}"
3839

3940
name = models.CharField(max_length=255)
41+
name.help_text = ("Full project name, shown as the title on the project page and as the "
42+
"heading on cards (e.g., \"Project Sidewalk\").")
43+
44+
# Optional short label for compact UI (publication/talk/video cards). Falls
45+
# back to `name` via get_display_short_name() when left blank (#1156). This is
46+
# a *display* name, distinct from `short_name` (the URL slug) below.
47+
display_short_name = models.CharField(max_length=255, blank=True, null=True)
48+
display_short_name.help_text = ("Optional short label shown in compact places like publication, "
49+
"talk, and video cards (e.g., \"Sidewalk\"). Leave blank to use "
50+
"the full name.")
4051

4152
# Short name is used for urls, and should be name.lower().replace(" ", "")
4253
short_name = models.CharField(max_length=255)
43-
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"
54+
short_name.help_text = ("URL slug only — lowercase, no spaces (e.g., \"projectsidewalk\"). "
55+
"Used in the project's web address, not shown to readers.")
4456

4557
# is_visible is the single source of truth for whether a project appears
4658
# publicly (gallery, landing page, member pages, and as links from
@@ -105,6 +117,32 @@ def get_thumbnail_size_as_str():
105117

106118
updated = models.DateField(auto_now=True)
107119

120+
def clean(self):
121+
"""
122+
Validate that short_name (the URL slug) is unique case-insensitively.
123+
124+
The project view resolves /projects/<slug>/ with
125+
``short_name__iexact`` (see views/project.py), so two projects sharing a
126+
slug — even differing only in case — make get_object_or_404 raise
127+
MultipleObjectsReturned, i.e. a 500 on *both* project pages. There is no
128+
DB-level unique constraint yet (existing data must be de-duped first), so
129+
enforce it at the form layer here; the admin runs full_clean() and will
130+
surface this as a field error (#1156).
131+
"""
132+
super().clean()
133+
if self.short_name:
134+
clash = Project.objects.filter(short_name__iexact=self.short_name)
135+
if self.pk:
136+
clash = clash.exclude(pk=self.pk)
137+
if clash.exists():
138+
raise ValidationError({
139+
'short_name': (
140+
f'A project with the slug "{self.short_name}" already exists. '
141+
f'Slugs are compared case-insensitively because they are used '
142+
f'in project URLs. Please choose a different short name.'
143+
)
144+
})
145+
108146
def save(self, *args, **kwargs):
109147
"""
110148
This method overrides the default save method for the Project model.
@@ -631,5 +669,14 @@ def get_project_dates_str(self):
631669
return f"{self.start_date.year}{self.end_date.year}"
632670

633671

672+
def get_display_short_name(self):
673+
"""
674+
Returns the short display label for compact UI (publication, talk, and
675+
video cards). Falls back to the full `name` when `display_short_name` is
676+
blank or unset (#1156). Note this is distinct from `short_name`, which is
677+
the lowercase, no-spaces URL slug.
678+
"""
679+
return self.display_short_name or self.name
680+
634681
def __str__(self):
635682
return self.name

website/templates/snippets/display_pub_snippet.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ <h3 class="artifact-title line-clamp" style="margin-top: 0;">{{ pub.title }}</h3
125125
{% for project in pub.projects.all %}
126126
{% if project.can_show_online %}
127127
<a href="{% url 'website:project' project.short_name %}" aria-label="View project: {{ project.name }}">
128-
<i class="fa-solid fa-flask" aria-hidden="true"></i>{{ project.name }}
128+
<i class="fa-solid fa-flask" aria-hidden="true"></i>{{ project.get_display_short_name }}
129129
</a>
130130
{% endif %}
131131
{% endfor %}
@@ -255,7 +255,7 @@ <h3 class="artifact-title pub-title">{{ pub.title }}</h3>
255255
{% for project in pub.projects.all %}
256256
{% if project.can_show_online %}
257257
<a href="{% url 'website:project' project.short_name %}" aria-label="View project: {{ project.name }}">
258-
<i class="fa-solid fa-flask" aria-hidden="true"></i>{{ project.name }}
258+
<i class="fa-solid fa-flask" aria-hidden="true"></i>{{ project.get_display_short_name }}
259259
</a>
260260
{% endif %}
261261
{% endfor %}

website/templates/snippets/display_talk_snippet.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ <h3 class="talk-title">
123123
<a href="{% url 'website:project' project.short_name %}"
124124
aria-label="View project: {{ project.name }}">
125125
<i class="fa-solid fa-flask" aria-hidden="true"></i>
126-
<span>{{ project.name }}</span>
126+
<span>{{ project.get_display_short_name }}</span>
127127
</a>
128128
{% endif %}
129129
{% endfor %}

website/templates/snippets/display_video_snippet.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ <h3 class="video-title">
8282
<a href="{% url 'website:project' project.short_name %}"
8383
aria-label="View project: {{ project.name }}">
8484
<i class="fa-solid fa-flask" aria-hidden="true"></i>
85-
<span>{{ project.name }}</span>
85+
<span>{{ project.get_display_short_name }}</span>
8686
</a>
8787
{% endif %}
8888
{% endfor %}

website/tests/test_project.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,76 @@
33
from datetime import date, timedelta
44
from unittest.mock import MagicMock
55

6+
from django.core.exceptions import ValidationError
67
from django.test import SimpleTestCase
78

89
from website.models.project import Project
910
from website.tests.base import DatabaseTestCase
1011

1112

13+
# --- Project display short name (#1156) ------------------------------------
14+
15+
16+
class ProjectDisplayShortNameTests(SimpleTestCase):
17+
"""
18+
Tests for Project.get_display_short_name, the short label shown on compact
19+
publication/talk/video cards (#1156). It returns `display_short_name` when
20+
set and falls back to the full `name` when blank or unset. (`short_name` is
21+
the URL slug and is intentionally not used here.)
22+
"""
23+
24+
def _display(self, name, display_short_name):
25+
obj = MagicMock()
26+
obj.name = name
27+
obj.display_short_name = display_short_name
28+
return Project.get_display_short_name(obj)
29+
30+
def test_returns_display_short_name_when_set(self):
31+
self.assertEqual(self._display("Project Sidewalk", "Sidewalk"), "Sidewalk")
32+
33+
def test_falls_back_to_name_when_none(self):
34+
self.assertEqual(self._display("Project Sidewalk", None), "Project Sidewalk")
35+
36+
def test_falls_back_to_name_when_empty(self):
37+
self.assertEqual(self._display("Project Sidewalk", ""), "Project Sidewalk")
38+
39+
40+
# --- Project short_name (slug) uniqueness (#1156) --------------------------
41+
42+
43+
class ProjectShortNameUniquenessTests(DatabaseTestCase):
44+
"""
45+
Project.clean() rejects a short_name that collides (case-insensitively) with
46+
an existing project's slug. The view resolves /projects/<slug>/ via
47+
short_name__iexact, so a duplicate slug would 500 (MultipleObjectsReturned)
48+
both project pages.
49+
"""
50+
51+
def test_duplicate_slug_rejected(self):
52+
self.make_project(name="Project Sidewalk", short_name="projectsidewalk")
53+
dupe = Project(name="Sidewalk Redux", short_name="projectsidewalk")
54+
with self.assertRaises(ValidationError) as ctx:
55+
dupe.full_clean()
56+
self.assertIn("short_name", ctx.exception.message_dict)
57+
58+
def test_duplicate_slug_rejected_case_insensitively(self):
59+
self.make_project(name="Project Sidewalk", short_name="projectsidewalk")
60+
dupe = Project(name="Sidewalk Redux", short_name="ProjectSidewalk")
61+
with self.assertRaises(ValidationError) as ctx:
62+
dupe.full_clean()
63+
self.assertIn("short_name", ctx.exception.message_dict)
64+
65+
def test_unique_slug_allowed(self):
66+
self.make_project(name="Project Sidewalk", short_name="projectsidewalk")
67+
ok = Project(name="Project Aware", short_name="projectaware")
68+
ok.full_clean() # should not raise
69+
70+
def test_editing_existing_project_keeps_its_own_slug(self):
71+
proj = self.make_project(name="Project Sidewalk", short_name="projectsidewalk")
72+
proj.summary = "Updated summary"
73+
proj.full_clean() # its own slug must not count as a clash
74+
75+
1276
# --- Project date-range string --------------------------------------------
1377

1478

0 commit comments

Comments
 (0)