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
4 changes: 3 additions & 1 deletion website/admin/artifact_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}),
Expand Down
2 changes: 2 additions & 0 deletions website/admin/award_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
6 changes: 5 additions & 1 deletion website/admin/grant_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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']}),
Expand Down
5 changes: 5 additions & 0 deletions website/admin/keyword_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 21 additions & 6 deletions website/admin/news_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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']
Expand All @@ -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('<img src="{}" height="50" style="border-radius: 5%;"/>', 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('<img src="{}" height="50" style="border-radius: 5%;"/>', 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'
Expand Down
5 changes: 4 additions & 1 deletion website/admin/photo_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions website/admin/position_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion website/admin/poster_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
4 changes: 3 additions & 1 deletion website/admin/project_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions website/admin/project_umbrella_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 7 additions & 0 deletions website/admin/publication_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions website/admin/sponsor_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 3 additions & 1 deletion website/admin/talk_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
"""
Expand Down
5 changes: 3 additions & 2 deletions website/admin/video_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()])
Expand Down
100 changes: 100 additions & 0 deletions website/tests/test_admin_changelist.py
Original file line number Diff line number Diff line change
@@ -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',))
Loading