diff --git a/website/admin/artifact_admin.py b/website/admin/artifact_admin.py
index fc84e0e3..2f119856 100644
--- a/website/admin/artifact_admin.py
+++ b/website/admin/artifact_admin.py
@@ -15,7 +15,9 @@ class ArtifactAdmin(admin.ModelAdmin):
# search_fields are used for auto-complete, see:
# https://docs.djangoproject.com/en/3.0/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields
- search_fields = ['title', 'forum_name']
+ # Includes author first/last name so artifacts are findable by who wrote them
+ # (Django auto-applies DISTINCT for the M2M join). Subclasses may extend this.
+ search_fields = ['title', 'forum_name', 'authors__first_name', 'authors__last_name']
fieldsets = [
(None, {'fields': ['title', 'authors', 'date']}),
diff --git a/website/admin/award_admin.py b/website/admin/award_admin.py
index 1e6293dd..162c87c4 100644
--- a/website/admin/award_admin.py
+++ b/website/admin/award_admin.py
@@ -44,6 +44,8 @@ class AwardAdmin(admin.ModelAdmin):
ordering = ('-date',)
+ date_hierarchy = 'date' # Year/month/day drill-down (awards are browsed by year)
+
def get_fieldsets(self, request, obj=None):
# Built at request time so reverse() can resolve the Publications admin URL.
publications_url = reverse('admin:website_publication_changelist')
diff --git a/website/admin/grant_admin.py b/website/admin/grant_admin.py
index 1599e694..0b249516 100644
--- a/website/admin/grant_admin.py
+++ b/website/admin/grant_admin.py
@@ -9,7 +9,10 @@ class GrantAdmin(ArtifactAdmin):
# search_fields are used for auto-complete, see:
# https://docs.djangoproject.com/en/3.0/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields
- search_fields = ['title', 'date', 'forum_name']
+ # Dropped 'date' (string-searching a DateField is unhelpful); added PI/Co-PI
+ # (author) and sponsor name so grants are findable by people and funder.
+ search_fields = ['title', 'forum_name', 'authors__first_name',
+ 'authors__last_name', 'sponsor__name']
# The list display lets us control what is shown in the default talk table at Home > Website > Grants
# See: https://docs.djangoproject.com/en/dev/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_display
@@ -21,6 +24,7 @@ class GrantAdmin(ArtifactAdmin):
autocomplete_fields = ['sponsor']
ordering = ('-date',) # sort by date, most recent first
+ date_hierarchy = 'date' # Year/month/day drill-down by grant start date
fieldsets = [
(None, {'fields': ['title', 'authors']}),
diff --git a/website/admin/keyword_admin.py b/website/admin/keyword_admin.py
index bb1b748d..a2484ceb 100644
--- a/website/admin/keyword_admin.py
+++ b/website/admin/keyword_admin.py
@@ -7,6 +7,11 @@
class KeywordAdmin(admin.ModelAdmin):
list_display = ['keyword', 'project_count', 'publication_count']
+ # The keyword table had no search box; alphabetical ordering also groups
+ # near-duplicate tags (e.g. "Speech" / "speech") adjacently for cleanup.
+ search_fields = ['keyword']
+ ordering = ['keyword']
+
def change_view(self, request, object_id, form_url='', extra_context=None):
"""Add projects and publications to the context. We then use this extra data in
the change_form.html template to display the projects and publications that use this keyword.
diff --git a/website/admin/news_admin.py b/website/admin/news_admin.py
index 786b8034..0cc7f9be 100644
--- a/website/admin/news_admin.py
+++ b/website/admin/news_admin.py
@@ -12,8 +12,11 @@
from django.utils.html import format_html # for formatting thumbnails
from easy_thumbnails.files import get_thumbnailer # for generating thumbnails
import os # for checking if thumbnail file exists
+import logging
from website.admin.admin_site import ml_admin_site
+_logger = logging.getLogger(__name__)
+
class YearListFilter(admin.SimpleListFilter):
title = 'year' # a label for our filter
parameter_name = 'year' # you can put anything here
@@ -42,6 +45,12 @@ class NewsAdmin(ImageCroppingMixin, admin.ModelAdmin):
# Add a filter to the right sidebar that allows us to filter by year
list_filter = (YearListFilter, 'project')
+ # Search by headline or author name (News previously had no search box).
+ search_fields = ['title', 'author__first_name', 'author__last_name']
+
+ # Year/month/day drill-down at the top of the changelist (News is date-driven).
+ date_hierarchy = 'date'
+
# Define 'author' as an auto-complete field. We must then also define "search_fields"
# in PersonAdmin or we'll receive a Django error
autocomplete_fields = ['author']
@@ -51,12 +60,18 @@ class NewsAdmin(ImageCroppingMixin, admin.ModelAdmin):
def get_display_thumbnail(self, obj):
if obj.image and os.path.isfile(obj.image.path):
- # Use easy_thumbnails to generate a thumbnail
- thumbnailer = get_thumbnailer(obj.image)
- thumbnail_options = {'size': (NEWS_THUMBNAIL_SIZE[0], NEWS_THUMBNAIL_SIZE[1]), 'crop': True}
- thumbnail_url = thumbnailer.get_thumbnail(thumbnail_options).url
-
- return format_html('
', thumbnail_url)
+ try:
+ # Use easy_thumbnails to generate a thumbnail
+ thumbnailer = get_thumbnailer(obj.image)
+ thumbnail_options = {'size': (NEWS_THUMBNAIL_SIZE[0], NEWS_THUMBNAIL_SIZE[1]), 'crop': True}
+ thumbnail_url = thumbnailer.get_thumbnail(thumbnail_options).url
+
+ return format_html('
', thumbnail_url)
+ except Exception:
+ # A single corrupt/unreadable image must not 500 the entire News
+ # changelist (the column is rendered for every row).
+ _logger.warning("Could not generate admin thumbnail for News id=%s", obj.pk, exc_info=True)
+ return 'No Thumbnail'
return 'No Thumbnail'
get_display_thumbnail.short_description = 'Thumbnail'
diff --git a/website/admin/photo_admin.py b/website/admin/photo_admin.py
index 25510a26..f1695cbc 100644
--- a/website/admin/photo_admin.py
+++ b/website/admin/photo_admin.py
@@ -7,5 +7,8 @@
class PhotoAdmin(ImageCroppingMixin, admin.ModelAdmin):
list_display = ('admin_thumbnail', 'caption', 'alt_text', 'get_resolution_as_str',
'cropping', 'picture')
-
+
+ # Photos had no search box; search caption/alt text and the owning project.
+ search_fields = ['caption', 'alt_text', 'project__name']
+
list_per_page = 20 # changes how many images to show on a single admin page
\ No newline at end of file
diff --git a/website/admin/position_admin.py b/website/admin/position_admin.py
index 26e8ec30..83dcc320 100644
--- a/website/admin/position_admin.py
+++ b/website/admin/position_admin.py
@@ -8,6 +8,10 @@
class PositionAdmin(admin.ModelAdmin):
"""Note: We do not want users to edit positions directly. Instead, we want them to edit people and projects.
See PositionInline in PersonAdmin"""
+ # Needed for autocomplete/search to filter rather than return everything;
+ # the get_search_results override below builds on this base.
+ search_fields = ['person__first_name', 'person__last_name', 'title']
+
def get_model_perms(self, request):
"""
Return empty perms dict thus hiding the model from admin index.
diff --git a/website/admin/poster_admin.py b/website/admin/poster_admin.py
index 4a193e77..fb88cf28 100644
--- a/website/admin/poster_admin.py
+++ b/website/admin/poster_admin.py
@@ -13,7 +13,12 @@ class PosterAdmin(ArtifactAdmin):
# search_fields are used for auto-complete, see:
# https://docs.djangoproject.com/en/3.0/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields
- search_fields = ['title', 'date']
+ # Was ['title', 'date'] — string-searching a DateField is unhelpful; search
+ # venue and author instead (matches the other artifact admins).
+ search_fields = ['title', 'forum_name', 'authors__first_name', 'authors__last_name']
+
+ ordering = ('-date',) # Poster had no default sort; newest first like its siblings
+ date_hierarchy = 'date' # Year/month/day drill-down
def get_changeform_initial_data(self, request):
"""
diff --git a/website/admin/project_admin.py b/website/admin/project_admin.py
index fa15e5f3..a9459984 100644
--- a/website/admin/project_admin.py
+++ b/website/admin/project_admin.py
@@ -48,7 +48,9 @@ class GrantInline(admin.TabularInline):
@admin.register(Project, site=ml_admin_site)
class ProjectAdmin(ImageCroppingMixin, admin.ModelAdmin):
- search_fields = ['name'] # allows you to search by the name of the project
+ # 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]
# The list display lets us control what is shown in the Project table at Home > Website > Project
diff --git a/website/admin/project_umbrella_admin.py b/website/admin/project_umbrella_admin.py
index 63e65e98..ba14d34b 100644
--- a/website/admin/project_umbrella_admin.py
+++ b/website/admin/project_umbrella_admin.py
@@ -11,6 +11,8 @@ class ProjectInline(admin.TabularInline): # or admin.StackedInline
@admin.register(ProjectUmbrella, site=ml_admin_site)
class ProjectUmbrellaAdmin(admin.ModelAdmin):
list_display = ('name', 'short_name', 'project_count')
+ search_fields = ['name', 'short_name']
+ ordering = ('name',)
inlines = [ProjectInline]
def formfield_for_manytomany(self, db_field, request=None, **kwargs):
diff --git a/website/admin/publication_admin.py b/website/admin/publication_admin.py
index 98285fd2..69c00508 100644
--- a/website/admin/publication_admin.py
+++ b/website/admin/publication_admin.py
@@ -47,6 +47,13 @@ class Media:
# default the sort order in table to descending order by date
ordering = ('-date',)
+ # Extend the ArtifactAdmin default (title/forum/authors) with book title and DOI.
+ search_fields = ['title', 'forum_name', 'book_title',
+ 'authors__first_name', 'authors__last_name', 'doi']
+
+ # Year/month/day drill-down for this large, date-ordered table.
+ date_hierarchy = 'date'
+
list_filter = (PubVenueTypeListFilter, PubVenueListFilter)
# add in auto-complete fields
diff --git a/website/admin/sponsor_admin.py b/website/admin/sponsor_admin.py
index 4c692acf..91c643b1 100644
--- a/website/admin/sponsor_admin.py
+++ b/website/admin/sponsor_admin.py
@@ -22,6 +22,8 @@ class SponsorAdmin(ImageCroppingMixin, admin.ModelAdmin):
# In this case, the admin will search the 'name' and 'short_name' fields of the Sponsor model.
search_fields = ['name', 'short_name']
+ ordering = ('name',) # deterministic alphabetical sort (none was defined)
+
def total_funding(self, obj):
return obj.grant_set.aggregate(total_funding=Sum('funding_amount'))['total_funding']
total_funding.short_description = 'Total Funding'
diff --git a/website/admin/talk_admin.py b/website/admin/talk_admin.py
index 438f6b98..fb774842 100644
--- a/website/admin/talk_admin.py
+++ b/website/admin/talk_admin.py
@@ -22,7 +22,8 @@ class TalkAdmin(ArtifactAdmin):
# search_fields are used for auto-complete, see:
# https://docs.djangoproject.com/en/3.0/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields
- search_fields = ['title', 'forum_name']
+ # Includes speaker (author) name so talks are findable by who gave them.
+ search_fields = ['title', 'forum_name', 'authors__first_name', 'authors__last_name']
# This auto-complete field is not working
# See: https://github.com/makeabilitylab/makeabilitylabwebsite/issues/1093#issuecomment-2423843958
@@ -42,6 +43,7 @@ class TalkAdmin(ArtifactAdmin):
ordering = ('-date',) # Sort talks by date in descending order
list_filter = ('talk_type',) # Add a filter for the talk type
+ date_hierarchy = 'date' # Year/month/day drill-down at the top of the list
def get_changeform_initial_data(self, request):
"""
diff --git a/website/admin/video_admin.py b/website/admin/video_admin.py
index fab91fb3..c93d6eb0 100644
--- a/website/admin/video_admin.py
+++ b/website/admin/video_admin.py
@@ -16,11 +16,12 @@ class VideoAdmin(admin.ModelAdmin):
# search_fields are used for auto-complete, see:
# https://docs.djangoproject.com/en/3.0/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields
# Jon's note (Dec 16, 2025): We cannot use 'get_video_host_str' here because search_fields requires actual model fields.
- search_fields = ['title', 'video_url', 'date', 'caption']
- # search_fields = ['title']
+ # Dropped 'date' (string-searching a DateField is unhelpful) and added project name.
+ search_fields = ['title', 'video_url', 'caption', 'projects__name']
# default the sort order in table to descending order by date
ordering = ('-date',)
+ date_hierarchy = 'date' # Year/month/day drill-down
def display_projects(self, obj):
return ", ".join([project.name for project in obj.projects.all()])
diff --git a/website/tests/test_admin_changelist.py b/website/tests/test_admin_changelist.py
new file mode 100644
index 00000000..5d6cf1db
--- /dev/null
+++ b/website/tests/test_admin_changelist.py
@@ -0,0 +1,100 @@
+"""
+Phase 1 admin changelist quick-wins (#1083, from the #1082 admin audit).
+
+Two kinds of coverage:
+ 1. A regression test for the News changelist thumbnail, which previously had
+ no exception guard — a single corrupt/unreadable image would 500 the whole
+ list page.
+ 2. Lightweight config assertions that lock in the search / ordering /
+ date_hierarchy added across the content admins, so they can't silently
+ regress.
+"""
+
+from django.core.files.base import ContentFile
+
+from website.models import News
+from website.admin.admin_site import ml_admin_site
+from website.admin.news_admin import NewsAdmin
+from website.admin.keyword_admin import KeywordAdmin
+from website.admin.publication_admin import PublicationAdmin
+from website.admin.talk_admin import TalkAdmin
+from website.admin.poster_admin import PosterAdmin
+from website.admin.video_admin import VideoAdmin
+from website.admin.grant_admin import GrantAdmin
+from website.admin.award_admin import AwardAdmin
+from website.admin.project_admin import ProjectAdmin
+from website.admin.project_umbrella_admin import ProjectUmbrellaAdmin
+from website.admin.photo_admin import PhotoAdmin
+from website.admin.position_admin import PositionAdmin
+from website.admin.sponsor_admin import SponsorAdmin
+from website.tests.base import DatabaseTestCase
+
+
+class NewsAdminThumbnailRobustnessTests(DatabaseTestCase):
+ """A corrupt image must not crash the News changelist (#1082 audit)."""
+
+ def test_corrupt_image_returns_placeholder_instead_of_raising(self):
+ news = self.make_news_item(title="Corrupt image news")
+ # Write non-image bytes so os.path.isfile() passes (the file exists) but
+ # easy_thumbnails raises InvalidImageFormatError when it tries to render.
+ news.image.save("not_an_image.jpg",
+ ContentFile(b"definitely not an image"), save=False)
+ self.addCleanup(lambda: news.image.delete(save=False))
+
+ admin = NewsAdmin(News, ml_admin_site)
+ # Must not raise; the guard returns the same placeholder as the no-image case.
+ self.assertEqual(admin.get_display_thumbnail(news), 'No Thumbnail')
+
+
+class AdminChangelistConfigTests(DatabaseTestCase):
+ """Lock in the Phase 1 search / ordering / date_hierarchy additions."""
+
+ def test_author_and_venue_search_on_artifacts(self):
+ for admin_cls in (PublicationAdmin, TalkAdmin, PosterAdmin):
+ self.assertIn('authors__last_name', admin_cls.search_fields,
+ f"{admin_cls.__name__} should be searchable by author")
+ self.assertEqual(admin_cls.date_hierarchy, 'date',
+ f"{admin_cls.__name__} should have a date drill-down")
+
+ def test_poster_no_longer_string_searches_date(self):
+ self.assertNotIn('date', PosterAdmin.search_fields)
+ self.assertEqual(PosterAdmin.ordering, ('-date',))
+
+ def test_video_search_and_hierarchy(self):
+ self.assertNotIn('date', VideoAdmin.search_fields)
+ self.assertIn('projects__name', VideoAdmin.search_fields)
+ self.assertEqual(VideoAdmin.date_hierarchy, 'date')
+
+ def test_grant_searchable_by_person_and_sponsor(self):
+ self.assertIn('authors__last_name', GrantAdmin.search_fields)
+ self.assertIn('sponsor__name', GrantAdmin.search_fields)
+ self.assertNotIn('date', GrantAdmin.search_fields)
+ self.assertEqual(GrantAdmin.date_hierarchy, 'date')
+
+ def test_award_date_hierarchy(self):
+ self.assertEqual(AwardAdmin.date_hierarchy, 'date')
+
+ def test_news_has_search_and_hierarchy(self):
+ self.assertIn('author__last_name', NewsAdmin.search_fields)
+ self.assertEqual(NewsAdmin.date_hierarchy, 'date')
+
+ def test_keyword_search_and_ordering(self):
+ self.assertEqual(KeywordAdmin.search_fields, ['keyword'])
+ self.assertEqual(KeywordAdmin.ordering, ['keyword'])
+
+ def test_project_search_and_ordering(self):
+ self.assertIn('project_umbrellas__name', ProjectAdmin.search_fields)
+ self.assertEqual(ProjectAdmin.ordering, ('name',))
+
+ def test_project_umbrella_search_and_ordering(self):
+ self.assertEqual(ProjectUmbrellaAdmin.search_fields, ['name', 'short_name'])
+ self.assertEqual(ProjectUmbrellaAdmin.ordering, ('name',))
+
+ def test_photo_search(self):
+ self.assertIn('project__name', PhotoAdmin.search_fields)
+
+ def test_position_search(self):
+ self.assertIn('person__last_name', PositionAdmin.search_fields)
+
+ def test_sponsor_ordering(self):
+ self.assertEqual(SponsorAdmin.ordering, ('name',))