22from django .db .models import Max , Min
33from django .db .models import F , ExpressionWrapper , fields , Sum , Q , Value
44from django .db .models .functions import Coalesce
5+ from django .core .exceptions import ValidationError
56
67from image_cropping import ImageRatioField
78from 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
0 commit comments