Skip to content
Merged
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
2 changes: 1 addition & 1 deletion website/admin/project_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}),
Expand Down
49 changes: 48 additions & 1 deletion website/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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/<slug>/ 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.
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions website/templates/snippets/display_pub_snippet.html
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ <h3 class="artifact-title line-clamp" style="margin-top: 0;">{{ pub.title }}</h3
{% for project in pub.projects.all %}
{% if project.can_show_online %}
<a href="{% url 'website:project' project.short_name %}" aria-label="View project: {{ project.name }}">
<i class="fa-solid fa-flask" aria-hidden="true"></i>{{ project.name }}
<i class="fa-solid fa-flask" aria-hidden="true"></i>{{ project.get_display_short_name }}
</a>
{% endif %}
{% endfor %}
Expand Down Expand Up @@ -255,7 +255,7 @@ <h3 class="artifact-title pub-title">{{ pub.title }}</h3>
{% for project in pub.projects.all %}
{% if project.can_show_online %}
<a href="{% url 'website:project' project.short_name %}" aria-label="View project: {{ project.name }}">
<i class="fa-solid fa-flask" aria-hidden="true"></i>{{ project.name }}
<i class="fa-solid fa-flask" aria-hidden="true"></i>{{ project.get_display_short_name }}
</a>
{% endif %}
{% endfor %}
Expand Down
2 changes: 1 addition & 1 deletion website/templates/snippets/display_talk_snippet.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ <h3 class="talk-title">
<a href="{% url 'website:project' project.short_name %}"
aria-label="View project: {{ project.name }}">
<i class="fa-solid fa-flask" aria-hidden="true"></i>
<span>{{ project.name }}</span>
<span>{{ project.get_display_short_name }}</span>
</a>
{% endif %}
{% endfor %}
Expand Down
2 changes: 1 addition & 1 deletion website/templates/snippets/display_video_snippet.html
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ <h3 class="video-title">
<a href="{% url 'website:project' project.short_name %}"
aria-label="View project: {{ project.name }}">
<i class="fa-solid fa-flask" aria-hidden="true"></i>
<span>{{ project.name }}</span>
<span>{{ project.get_display_short_name }}</span>
</a>
{% endif %}
{% endfor %}
Expand Down
64 changes: 64 additions & 0 deletions website/tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<slug>/ 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 --------------------------------------------


Expand Down
Loading