diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py index 87297374..1e0fbd24 100644 --- a/makeabilitylab/settings.py +++ b/makeabilitylab/settings.py @@ -86,8 +86,8 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # Makeability Lab Global Variables, including Makeability Lab version -ML_WEBSITE_VERSION = "2.28.0" # Keep this updated with each release and also change the short description below -ML_WEBSITE_VERSION_DESCRIPTION = "Adds five fields to the public v1 REST API so Project Sidewalk's new About page can render its team section straight from this site instead of hand-maintaining a roster (#1426). /api/v1/people// gains current_school and current_department alongside the existing current_title; /api/v1/projects//people/ gains position_title, position_school, and position_school_abbreviated -- what the person WAS when they joined the project, not what they are now, so a 2015 stint reads 'Undergrad, UMD' even though that person may be a professor today. (Jon's own 2012 Sidewalk role now correctly reports Assistant Professor / UMD rather than Professor / UW.) The new fields are named position_* rather than a bare 'title' so they don't read as part of 'role', the editor-written free-text description sitting beside them in the same record. Which position gets picked is an explicit, documented rule on ProjectRole.position_during_role: the position containing the role's start date, else the latest one already begun (the role started in a gap between positions), else the earliest (the role predates every recorded position -- common in older imported data), and null only for someone with no Position at all; ties on start date break on pk so two positions beginning the same day can't let DB row order decide the answer from one request to the next. Both project sub-resources now prefetch person__position_set and the people list prefetches position_set, so resolving a position per row rides the prefetch instead of costing a query each -- that matters at Sidewalk's ~150-person roster, and it also fixes a pre-existing N+1 behind current_title; a regression test pins the query count as flat as the roster grows. Separately, get_school_abbreviated is tightened twice: it no longer acronyms one-word affiliations (not every school is a university, and 'Easterseals' -> 'E' (or 'Cornell' -> 'C') is worse than the name itself), and its UW/UMD special cases now match the phrase 'university of washington/maryland' rather than a bare 'washington'/'maryland' substring, which used to hand UW's initials to Washington State and Washington University in St. Louis, and UMD's to UMBC. That is a visible fix on the public People page too, not just in the API. No model changes and no migration; v1 stays additive-only, so existing consumers are unaffected." +ML_WEBSITE_VERSION = "2.28.1" # Keep this updated with each release and also change the short description below +ML_WEBSITE_VERSION_DESCRIPTION = "Adds two quick links to the admin header so a pair of easy-to-forget internal pages stop being invisible: Project People (/view-project-people/, the project-team roster, which nothing in the main site nav links to) and Activity Log (the site-wide admin action log added in #1413). The Activity Log link is wrapped in {% if user.is_superuser %} so it matches the gate on LogEntryAdmin itself -- editors and contributors never see a link to a page they cannot open, and the log's existence is not advertised to them. The roster link is labeled 'Project People' to match that page's own heading rather than inventing a second name for the same page, and both new-tab links now carry a visually-hidden '(opens in a new tab)' cue built on Django admin's own screen-reader helper class, so no new CSS ships. Also fixes a NoReverseMatch this change would otherwise have shipped: website/urls.py sets app_name = 'website', so the roster reverses as 'website:view_project_people', and the bare name raised inside the userlinks block of base_site.html -- the parent template of EVERY admin page -- which would have 500'd the whole admin for any logged-in staff user rather than merely breaking one link (and, on -test where DEBUG is True, shown a public traceback). It slipped past manual testing because hitting /view-project-people/ exercises the URL path rather than the reverse name, and an unauthenticated admin request 302s to login without ever rendering the template. A new regression test renders the admin index as both a superuser and a staff non-superuser, pinning both reverses, the superuser gate, and the new-tab cue, so a future URL-name rename fails a test instead of taking down the admin." DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed MAX_BANNERS = 7 # Maximum number of banners on a page diff --git a/website/templates/admin/base_site.html b/website/templates/admin/base_site.html index 562434eb..c3a162b1 100644 --- a/website/templates/admin/base_site.html +++ b/website/templates/admin/base_site.html @@ -15,7 +15,15 @@ {% endblock %} {% block userlinks %} - GitHub / + {% comment %} + Label matches the target page's own

/ ("Project People Viewer") + so the link and the page it lands on agree. Both new-tab links carry a + visually-hidden warning: `.visually-hidden` is Django admin's own + screen-reader-only helper (admin/css/base.css), so no new CSS is needed. + {% endcomment %} + <a href="{% url 'website:view_project_people' %}" target="_blank" rel="noopener">Project People<span class="visually-hidden"> (opens in a new tab)</span></a> / + {% if user.is_superuser %}<a href="{% url 'admin:admin_logentry_changelist' %}">Activity Log</a> /{% endif %} + <a href="https://github.com/makeabilitylab/makeabilitylabwebsite" target="_blank" rel="noopener">GitHub<span class="visually-hidden"> (opens in a new tab)</span></a> / {{ block.super }} {% endblock %} diff --git a/website/tests/test_admin_header_links.py b/website/tests/test_admin_header_links.py new file mode 100644 index 00000000..d3e5f9e2 --- /dev/null +++ b/website/tests/test_admin_header_links.py @@ -0,0 +1,58 @@ +""" +Regression tests for the quick links in the admin header (``userlinks`` block +of ``website/templates/admin/base_site.html``). + +Two properties matter: (1) both ``{% url %}`` names still resolve—a rename +would raise ``NoReverseMatch`` and 500 *every* admin page, not just the linked +one; and (2) the Activity Log link stays superuser-only, matching the gate on +``LogEntryAdmin`` itself. +""" + +from django.contrib.auth import get_user_model +from django.urls import reverse + +from website.tests.base import DatabaseTestCase + +User = get_user_model() + + +class AdminHeaderQuickLinkTests(DatabaseTestCase): + """The admin header renders, and the Activity Log link is superuser-only.""" + + def setUp(self): + self.superuser = User.objects.create_superuser( + username="root", email="root@example.com", password="pw") + self.editor = User.objects.create_user( + username="editor", email="editor@example.com", password="pw", + is_staff=True) + + def test_superuser_sees_both_quick_links(self): + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:index")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, reverse("website:view_project_people")) + self.assertContains(response, reverse("admin:admin_logentry_changelist")) + + def test_staff_non_superuser_does_not_see_activity_log(self): + self.client.force_login(self.editor) + response = self.client.get(reverse("admin:index")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, reverse("website:view_project_people")) + self.assertNotContains( + response, reverse("admin:admin_logentry_changelist")) + + def test_project_people_target_is_publicly_reachable(self): + # The link opens in a new tab for any staff user, so it must not be + # gated behind a login the editor/contributor accounts may not pass. + response = self.client.get(reverse("website:view_project_people")) + self.assertEqual(response.status_code, 200) + + def test_new_tab_links_announce_themselves(self): + # target="_blank" without a warning is a WCAG 3.2.5 nit; the cue is a + # visually-hidden span (Django admin's own screen-reader helper class). + self.client.force_login(self.superuser) + response = self.client.get(reverse("admin:index")) + self.assertContains( + response, + '<span class="visually-hidden"> (opens in a new tab)</span>', + count=2, html=False)