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',))