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
135 changes: 85 additions & 50 deletions website/admin/keyword_admin.py
Original file line number Diff line number Diff line change
@@ -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'

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'
18 changes: 17 additions & 1 deletion website/admin/project_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}),
Expand Down Expand Up @@ -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.')
27 changes: 26 additions & 1 deletion website/admin/publication_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
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.')
130 changes: 130 additions & 0 deletions website/tests/test_admin_actions.py
Original file line number Diff line number Diff line change
@@ -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)
Loading