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
5 changes: 5 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ echo "4.8 Running 'python manage.py recompute_url_names' to de-collide historica
echo "******************************************"
python manage.py recompute_url_names

echo "****************** STEP 4.8b/5: docker-entrypoint.sh ************************"
echo "4.8b Running 'python manage.py seed_project_aliases' to redirect renamed project slugs (#944)"
echo "******************************************"
python manage.py seed_project_aliases

echo "****************** STEP 4.9/5: docker-entrypoint.sh ************************"
echo "4.9 Running 'python manage.py propagate_publication_projects' to link talks/videos/posters to their publication's projects (#649)"
echo "******************************************"
Expand Down
17 changes: 15 additions & 2 deletions website/admin/project_admin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from django.contrib import admin
from django.contrib.admin import widgets
from website.models import (Project, Banner, Photo, ProjectRole, Grant,
from website.models import (Project, ProjectAlias, Banner, Photo, ProjectRole, Grant,
Publication, Talk, Video, Person)
from website.models.project import PROJECT_THUMBNAIL_SIZE
from website.admin_list_filters import ActiveProjectsFilter
Expand Down Expand Up @@ -65,12 +65,25 @@ class GrantInline(admin.TabularInline):
model = Grant.projects.through
extra = 1

class ProjectAliasInline(admin.TabularInline):
"""Former URL slugs that 301-redirect to this project (#944).

Rows appear automatically when a project's short_name is changed (see
Project.save()); editors can also add historical aliases by hand here so old
links keep resolving."""
model = ProjectAlias
extra = 0
fields = ['slug', 'created']
readonly_fields = ['created']
verbose_name = "Former slug (redirect)"
verbose_name_plural = "Former slugs (redirect to this project)"

@admin.register(Project, site=ml_admin_site)
class ProjectAdmin(ImageCroppingMixin, admin.ModelAdmin):
# Search by name plus the research-area facets editors think in (umbrella, keyword).
search_fields = ['name', 'short_name', 'project_umbrellas__name', 'keywords__name']
ordering = ('name',) # deterministic alphabetical sort (matched the autocomplete already)
inlines = [GrantInline, BannerInline, PhotoInline, ProjectRoleInline]
inlines = [GrantInline, BannerInline, PhotoInline, ProjectRoleInline, ProjectAliasInline]

# The list display lets us control what is shown in the Project table at Home > Website > Project
# info on displaying multiple entries comes from http://stackoverflow.com/questions/9164610/custom-columns-using-django-admin
Expand Down
67 changes: 67 additions & 0 deletions website/management/commands/seed_project_aliases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import logging
from django.core.management.base import BaseCommand
from website.models import Project, ProjectAlias

_logger = logging.getLogger(__name__)

# Historical project renames that predate the auto-capture in Project.save()
# (#944). Each entry maps a *former* slug to the project's *current* slug. Going
# forward, renames record their own aliases automatically; this list only
# backfills the ones that already happened.
#
# Keep keys/values lowercase (slugs are matched case-insensitively). The command
# is idempotent and safe to run on every deploy: it skips any entry whose target
# project doesn't exist or whose old slug is already a live project.
#
# Current slugs confirmed against prod 2026-06-23.
#
# smarthomedhh -> homesound was renamed in the admin *before* the auto-capture in
# Project.save() shipped, so no alias was recorded and /project/smarthomedhh/
# 404s. It's backfilled here. (Renames done after this feature deploys capture
# their own aliases automatically and don't need an entry.)
HISTORICAL_ALIASES = {
'mapoutloud': 'geovisally',
'mixed-ability-art': 'artinsight',
'smarthomedhh': 'homesound',
}


class Command(BaseCommand):
help = ("Idempotently backfills ProjectAlias rows for known historical project "
"renames so their old /project/<slug>/ URLs 301-redirect (#944).")

def handle(self, *args, **options):
created, skipped = 0, 0
for old_slug, current_slug in HISTORICAL_ALIASES.items():
old_slug = old_slug.strip().lower()
current_slug = current_slug.strip().lower()

# Don't shadow a live project: if the old slug now resolves to a real
# project, an alias for it would be dead (the live project always wins).
if Project.objects.filter(short_name__iexact=old_slug).exists():
_logger.info(f"seed_project_aliases: '{old_slug}' is a live slug; skipping.")
skipped += 1
continue

project = Project.objects.filter(short_name__iexact=current_slug).first()
if project is None:
_logger.warning(
f"seed_project_aliases: target project '{current_slug}' not found; "
f"skipping alias '{old_slug}'.")
skipped += 1
continue

alias, was_created = ProjectAlias.objects.get_or_create(
slug=old_slug, defaults={'project': project})
if was_created:
_logger.info(f"seed_project_aliases: created alias {old_slug} → {current_slug}")
created += 1
else:
# Repoint if an existing alias drifted to the wrong project.
if alias.project_id != project.id:
alias.project = project
alias.save()
_logger.info(f"seed_project_aliases: repointed alias {old_slug} → {current_slug}")
skipped += 1

self.stdout.write(f"seed_project_aliases: {created} created, {skipped} skipped.")
1 change: 1 addition & 0 deletions website/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from .position import Position
from .poster import Poster
from .project import Project
from .project_alias import ProjectAlias
from .project_role import ProjectRole
from .project_umbrella import ProjectUmbrella
from .publication import Publication
Expand Down
41 changes: 41 additions & 0 deletions website/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,23 @@ def clean(self):
)
})

# The slug namespace also includes *retired* slugs (ProjectAlias), each
# of which 301-redirects to its project (#944). Reject a short_name that
# collides with another project's alias, or the alias's redirect and this
# new project would fight over the same URL. Reclaiming this project's own
# former slug is fine — save() clears that alias.
from website.models.project_alias import ProjectAlias
alias_clash = (ProjectAlias.objects
.filter(slug__iexact=self.short_name)
.exclude(project=self if self.pk else None))
if alias_clash.exists():
raise ValidationError({
'short_name': (
f'The slug "{self.short_name}" is a former slug of another project '
f'and still redirects there. Please choose a different short name.'
)
})

def save(self, *args, **kwargs):
"""
This method overrides the default save method for the Project model.
Expand All @@ -154,6 +171,16 @@ def save(self, *args, **kwargs):
"""
_logger.debug("Running Project.save() method...")

# Capture the slug as it currently stands in the DB *before* saving, so we
# can detect a rename below and record the old slug as a redirecting alias
# (#944). None for a brand-new project (no row yet).
old_short_name = None
if self.pk is not None:
old_short_name = (Project.objects
.filter(pk=self.pk)
.values_list('short_name', flat=True)
.first())

# New projects are private by default (issue #1300). We set this at the
# model layer (rather than via a field default) so it applies to every
# creation path — admin, shell, seeds, tests — while leaving pre-existing
Expand All @@ -164,6 +191,20 @@ def save(self, *args, **kwargs):

super(Project, self).save(*args, **kwargs) # Save the Project instance first

# Auto-capture renames as redirecting aliases (#944). When short_name
# changes, the previous slug becomes a ProjectAlias pointing at this
# project so its old URL 301-redirects instead of 404ing.
if old_short_name and self.short_name and old_short_name.lower() != self.short_name.lower():
from website.models.project_alias import ProjectAlias
# This project just vacated old_short_name, so it's the rightful owner
# of that alias (update_or_create repoints any stale alias to us).
ProjectAlias.objects.update_or_create(
slug=old_short_name.lower(), defaults={'project': self})
# If this project reclaimed a slug that was previously an alias, drop
# that alias so it can't self-redirect (slug now resolves to a live
# project).
ProjectAlias.objects.filter(slug__iexact=self.short_name).delete()

if self.end_date:
# Get ProjectRoles related to the Project that have a null end_date
project_roles_to_close = ProjectRole.objects.filter(project=self, end_date__isnull=True)
Expand Down
82 changes: 82 additions & 0 deletions website/models/project_alias.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
from django.db import models
from django.core.exceptions import ValidationError

from .project import Project

import logging

_logger = logging.getLogger(__name__)


class ProjectAlias(models.Model):
"""A retired URL slug that should redirect to a project's current page.

Projects occasionally get renamed to a *completely different* name (e.g.
MapOutLoud -> GeoVisA11y), which also changes ``Project.short_name`` (the URL
slug). Without a record of the old slug, the previous URL hard-404s and any
external links / search-engine results to it break (#944, surfaced in the
#1142 SEO audit).

Each ``ProjectAlias`` maps one old slug -> one project. The project view
(``website/views/project.py``) consults this table when a slug doesn't match
a live project and issues a permanent (301) redirect to the current slug.
Rows are created automatically by ``Project.save()`` when a slug changes, and
can also be added by hand via the admin inline or seeded by the
``seed_project_aliases`` management command for historical renames.

Uniqueness: the slug namespace spans *both* live ``Project.short_name`` values
and these aliases. ``clean()`` enforces that a slug collides with neither, so
a slug always resolves to exactly one destination (no ambiguous redirects).
The live project always wins resolution, so an alias equal to a live slug
would be dead — hence it's rejected.
"""

slug = models.CharField(max_length=255, db_index=True)
slug.help_text = ("A former URL slug for this project (lowercase, no spaces). Visiting "
"/project/<this-slug>/ will 301-redirect to the project's current page.")

project = models.ForeignKey(Project, related_name='aliases', on_delete=models.CASCADE)

created = models.DateField(auto_now_add=True)

class Meta:
verbose_name = "Project slug alias"
verbose_name_plural = "Project slug aliases"

def __str__(self):
return f"{self.slug} → {self.project.short_name}"

def save(self, *args, **kwargs):
# Normalize so lookups (always done via __iexact, but stored lowercase for
# consistency) and the redirect target are predictable.
if self.slug:
self.slug = self.slug.strip().lower()
super().save(*args, **kwargs)

def clean(self):
"""Keep the combined (live slug + alias) namespace unique, case-insensitively.

Rejects a slug that (a) matches any live ``Project.short_name`` — the live
project would always win resolution, making the alias dead — or (b) matches
another ``ProjectAlias`` for a different destination.
"""
super().clean()
if not self.slug:
raise ValidationError({'slug': 'A slug is required.'})

slug = self.slug.strip().lower()

if Project.objects.filter(short_name__iexact=slug).exists():
raise ValidationError({
'slug': (f'"{slug}" is already a live project slug, so an alias for it would '
f'never be used. Aliases are only for *former* slugs.')
})

clash = ProjectAlias.objects.filter(slug__iexact=slug)
if self.pk:
clash = clash.exclude(pk=self.pk)
clash = clash.exclude(project=self.project)
if clash.exists():
raise ValidationError({
'slug': f'The alias "{slug}" already points to a different project.'
})
Loading
Loading