diff --git a/website/admin/keyword_admin.py b/website/admin/keyword_admin.py index a2484ceb..09f41df3 100644 --- a/website/admin/keyword_admin.py +++ b/website/admin/keyword_admin.py @@ -1,50 +1,85 @@ -from django.contrib import admin -from django.db.models import Count -from website.models import Keyword -from website.admin.admin_site import ml_admin_site - -@admin.register(Keyword, site=ml_admin_site) -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. - This change_form.html template is found in website/admin/templates/admin/website/keyword/change_form.html - """ - extra_context = extra_context or {} - keyword = Keyword.objects.get(pk=object_id) - extra_context['projects'] = keyword.project_set.all().order_by('-start_date') - extra_context['publications'] = keyword.publication_set.all().order_by('-date') - return super().change_view( - request, object_id, form_url, extra_context=extra_context, - ) - - def get_queryset(self, request): - """Annotate queryset with project and publication counts""" - queryset = super().get_queryset(request) - - # When you have multiple annotations, Django’s Count treats each instance of the related model - # (Project and Publication in this case) as separate instances, hence you get the same count for both. - # To get distinct counts for Project and Publication, you need to pass distinct=True to the Count function. - queryset = queryset.annotate(_project_count=Count("project", distinct=True), - _publication_count=Count("publication", distinct=True)) - return queryset - - def project_count(self, obj): - """Return the number of projects that use keyword""" - return obj._project_count - - project_count.admin_order_field = '_project_count' - - def publication_count(self, obj): - """Return the number of publications that use this keyword""" - return obj._publication_count - - publication_count.admin_order_field = '_publication_count' - \ No newline at end of file +from django.contrib import admin +from website.models import (Keyword, Publication, Talk, Poster, Grant, + Project, ProjectUmbrella) +from website.admin.utils import related_count_subquery +from website.admin.admin_site import ml_admin_site + +# Every model that has a `keywords` M2M to Keyword. Used to compute a keyword's +# *total* usage so the "Unused" filter is trustworthy: keywords lives on Artifact +# (Publication/Talk/Poster/Grant), Project, and ProjectUmbrella. (Video is not an +# Artifact, so it has no keywords.) +KEYWORD_USERS = (Publication, Talk, Poster, Grant, Project, ProjectUmbrella) + + +class KeywordUsageFilter(admin.SimpleListFilter): + """Filter keywords by whether anything references them — the core + taxonomy-cleanup need: find orphan tags to delete (#1346). Reads the + _total_usage annotation set in KeywordAdmin.get_queryset.""" + title = 'usage' + parameter_name = 'usage' + + def lookups(self, request, model_admin): + return (('unused', 'Unused (0 references)'), ('used', 'Used')) + + def queryset(self, request, queryset): + if self.value() == 'unused': + return queryset.filter(_total_usage=0) + if self.value() == 'used': + return queryset.filter(_total_usage__gt=0) + return queryset + + +@admin.register(Keyword, site=ml_admin_site) +class KeywordAdmin(admin.ModelAdmin): + list_display = ['keyword', 'project_count', 'publication_count', 'total_usage'] + + # 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'] + list_filter = (KeywordUsageFilter,) + + 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. + This change_form.html template is found in website/admin/templates/admin/website/keyword/change_form.html + """ + extra_context = extra_context or {} + keyword = Keyword.objects.get(pk=object_id) + extra_context['projects'] = keyword.project_set.all().order_by('-start_date') + extra_context['publications'] = keyword.publication_set.all().order_by('-date') + return super().change_view( + request, object_id, form_url, extra_context=extra_context, + ) + + def get_queryset(self, request): + """Annotate each keyword with project/publication counts plus a *total* + usage across all referencing models. Each count is an independent scalar + subquery (related_count_subquery) — summing several Count() joins in one + query would multiply rows and miscount; this stays correct and sortable. + """ + return super().get_queryset(request).annotate( + _project_count=related_count_subquery(Project, 'keywords'), + _publication_count=related_count_subquery(Publication, 'keywords'), + _total_usage=sum( + (related_count_subquery(model, 'keywords') for model in KEYWORD_USERS), + start=0, + ), + ) + + def project_count(self, obj): + """Return the number of projects that use keyword""" + return obj._project_count + project_count.admin_order_field = '_project_count' + + def publication_count(self, obj): + """Return the number of publications that use this keyword""" + return obj._publication_count + publication_count.admin_order_field = '_publication_count' + + def total_usage(self, obj): + """Total references across publications, talks, posters, grants, projects, + and project umbrellas (0 == an orphan keyword safe to delete).""" + return obj._total_usage + total_usage.short_description = 'Total Uses' + total_usage.admin_order_field = '_total_usage' diff --git a/website/admin/project_admin.py b/website/admin/project_admin.py index 4319974c..4884d256 100644 --- a/website/admin/project_admin.py +++ b/website/admin/project_admin.py @@ -84,7 +84,13 @@ class ProjectAdmin(ImageCroppingMixin, admin.ModelAdmin): # Bounds the per-row gallery-image filesystem check on the changelist (#1346). list_per_page = 50 - + + # Toggle public/private right in the list (is_visible renders as the plain + # checkbox formfield_for_dbfield defines below). 'name' stays the row link. + list_editable = ('is_visible',) + + actions = ('make_public', 'make_private') + fieldsets = [ (None, {'fields': ['name', 'short_name', 'is_visible']}), ('About', {'fields': ['start_date', 'end_date', 'summary', 'about', 'gallery_image', 'cropping', 'thumbnail_alt_text']}), @@ -248,3 +254,13 @@ def changelist_view(self, request, extra_context=None): request.GET = q request.META['QUERY_STRING'] = request.GET.urlencode() return super(ProjectAdmin,self).changelist_view(request, extra_context=extra_context) + + @admin.action(description='Mark selected projects as public (visible)') + def make_public(self, request, queryset): + updated = queryset.update(is_visible=True) + self.message_user(request, f'{updated} project(s) marked public.') + + @admin.action(description='Mark selected projects as private (hidden)') + def make_private(self, request, queryset): + updated = queryset.update(is_visible=False) + self.message_user(request, f'{updated} project(s) marked private.') diff --git a/website/admin/publication_admin.py b/website/admin/publication_admin.py index 69c00508..6507267f 100644 --- a/website/admin/publication_admin.py +++ b/website/admin/publication_admin.py @@ -14,6 +14,7 @@ from django.urls import reverse from django.utils.html import format_html +from django.http import HttpResponse from website.admin.admin_site import ml_admin_site @@ -173,4 +174,28 @@ def change_view(self, request, object_id, form_url='', extra_context=None): # Add the publication_id to the context so we can use it in the template extra_context = extra_context or {} extra_context['publication_id'] = object_id - return super().change_view(request, object_id, form_url, extra_context) \ No newline at end of file + return super().change_view(request, object_id, form_url, extra_context) + + actions = ('export_as_bibtex', 'mark_peer_reviewed', 'unmark_peer_reviewed') + + @admin.action(description='Export selected publications as BibTeX (.bib)') + def export_as_bibtex(self, request, queryset): + """Download the selected publications as a single .bib file. Uses the + model's get_citation_as_bibtex with plain newlines and no HTML hyperlinks + so the output is a valid BibTeX file rather than admin display markup.""" + entries = [pub.get_citation_as_bibtex(newline="\n", use_hyperlinks=False) + for pub in queryset] + response = HttpResponse("\n\n".join(entries), + content_type='application/x-bibtex; charset=utf-8') + response['Content-Disposition'] = 'attachment; filename="publications.bib"' + return response + + @admin.action(description='Mark selected publications as peer-reviewed') + def mark_peer_reviewed(self, request, queryset): + updated = queryset.update(peer_reviewed=True) + self.message_user(request, f'{updated} publication(s) marked peer-reviewed.') + + @admin.action(description='Mark selected publications as NOT peer-reviewed') + def unmark_peer_reviewed(self, request, queryset): + updated = queryset.update(peer_reviewed=False) + self.message_user(request, f'{updated} publication(s) marked not peer-reviewed.') \ No newline at end of file diff --git a/website/tests/test_admin_actions.py b/website/tests/test_admin_actions.py new file mode 100644 index 00000000..4f9cece0 --- /dev/null +++ b/website/tests/test_admin_actions.py @@ -0,0 +1,130 @@ +""" +Phase 4 of the admin changelist audit (#1346): workflow niceties — +list_editable toggles, bulk actions, and Keyword cleanup tooling. + +Covers: + - ProjectAdmin: inline is_visible toggle config + make_public/make_private actions. + - PublicationAdmin: export-as-BibTeX download + mark/unmark peer-reviewed actions. + - KeywordAdmin: total_usage counts every referencing model (so the "Unused" + filter doesn't flag a keyword that's only used by, e.g., a Talk). +""" + +from django.contrib.auth import get_user_model +from django.contrib.messages.storage.fallback import FallbackStorage +from django.http import HttpResponse +from django.test import RequestFactory + +from website.models import (Project, Publication, Keyword) +from website.admin.admin_site import ml_admin_site +from website.admin.project_admin import ProjectAdmin +from website.admin.publication_admin import PublicationAdmin +from website.admin.keyword_admin import KeywordAdmin +from website.tests.factories import (PersonFactory, ProjectFactory, + PublicationFactory, TalkFactory) +from website.tests.base import DatabaseTestCase + + +class _ActionTestBase(DatabaseTestCase): + @classmethod + def setUpTestData(cls): + cls.superuser = get_user_model().objects.create_superuser( + username="actionadmin", email="a@example.com", password="x") + + def setUp(self): + self.rf = RequestFactory() + + def _request(self): + """A POST request with a message store attached (admin actions that call + message_user need one).""" + request = self.rf.post('/') + request.user = self.superuser + request.session = {} + setattr(request, '_messages', FallbackStorage(request)) + return request + + +class ProjectActionTests(_ActionTestBase): + def setUp(self): + super().setUp() + self.admin = ProjectAdmin(Project, ml_admin_site) + + def test_is_visible_is_inline_editable(self): + self.assertIn('is_visible', self.admin.list_editable) + # list_editable fields must not be the row link column. + self.assertEqual(self.admin.list_display[0], 'name') + + def test_make_public_and_private(self): + p1 = ProjectFactory(is_visible=False) + p2 = ProjectFactory(is_visible=False) + self.admin.make_public(self._request(), Project.objects.filter(pk__in=[p1.pk, p2.pk])) + self.assertTrue(Project.objects.get(pk=p1.pk).is_visible) + self.assertTrue(Project.objects.get(pk=p2.pk).is_visible) + + self.admin.make_private(self._request(), Project.objects.filter(pk=p1.pk)) + self.assertFalse(Project.objects.get(pk=p1.pk).is_visible) + self.assertTrue(Project.objects.get(pk=p2.pk).is_visible) # unaffected + + +class PublicationActionTests(_ActionTestBase): + def setUp(self): + super().setUp() + self.admin = PublicationAdmin(Publication, ml_admin_site) + + def test_export_as_bibtex_downloads_bib_file(self): + pub = PublicationFactory(title="A Study of Things", + authors=[PersonFactory(last_name="Ng")]) + response = self.admin.export_as_bibtex( + self._request(), Publication.objects.filter(pk=pub.pk)) + self.assertIsInstance(response, HttpResponse) + self.assertIn('bibtex', response['Content-Type']) + self.assertIn('attachment', response['Content-Disposition']) + self.assertIn('.bib', response['Content-Disposition']) + body = response.content.decode() + self.assertIn('@inproceedings', body) # CONFERENCE venue type + self.assertIn('author=', body) + + def test_mark_and_unmark_peer_reviewed(self): + pub = PublicationFactory(peer_reviewed=None) + self.admin.mark_peer_reviewed(self._request(), Publication.objects.filter(pk=pub.pk)) + self.assertIs(Publication.objects.get(pk=pub.pk).peer_reviewed, True) + self.admin.unmark_peer_reviewed(self._request(), Publication.objects.filter(pk=pub.pk)) + self.assertIs(Publication.objects.get(pk=pub.pk).peer_reviewed, False) + + +class KeywordUsageTests(_ActionTestBase): + def setUp(self): + super().setUp() + self.admin = KeywordAdmin(Keyword, ml_admin_site) + + def _annotated(self, keyword): + return self.admin.get_queryset(self._request()).get(pk=keyword.pk) + + def test_total_usage_counts_all_referencing_models(self): + kw = Keyword.objects.create(keyword="ubicomp") + PublicationFactory().keywords.add(kw) + TalkFactory().keywords.add(kw) + ProjectFactory().keywords.add(kw) + ann = self._annotated(kw) + self.assertEqual(self.admin.total_usage(ann), 3) + self.assertEqual(self.admin.publication_count(ann), 1) + self.assertEqual(self.admin.project_count(ann), 1) + + def test_talk_only_keyword_is_not_unused(self): + """The whole point of broadening the count: a keyword used only by a Talk + shows project_count=0 and publication_count=0 but must NOT be 'Unused'.""" + kw = Keyword.objects.create(keyword="speech") + TalkFactory().keywords.add(kw) + ann = self._annotated(kw) + self.assertEqual(self.admin.project_count(ann), 0) + self.assertEqual(self.admin.publication_count(ann), 0) + self.assertEqual(self.admin.total_usage(ann), 1) + # The "Unused" predicate (_total_usage=0) must not match it. + unused_pks = self.admin.get_queryset(self._request()).filter( + _total_usage=0).values_list('pk', flat=True) + self.assertNotIn(kw.pk, list(unused_pks)) + + def test_orphan_keyword_is_unused(self): + kw = Keyword.objects.create(keyword="orphan") + unused_pks = list(self.admin.get_queryset(self._request()).filter( + _total_usage=0).values_list('pk', flat=True)) + self.assertIn(kw.pk, unused_pks)