From 502e8db8828c2c0f0c26910dd1678ef426aa1ee6 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Wed, 12 Aug 2026 15:36:46 -0700 Subject: [PATCH 1/3] Add UW grant tracking: worktag/award fields + admin tracker links (#1448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two admin-only additions around grants. 1. Three internal UW/Workday fields on Grant: uw_grant_id (the grant worktag, comma-separated when an award spans several), uw_award_number, and uw_award_name. All nullable. Shown in a "UW Internal Tracking" fieldset, with the worktag as a changelist column and all three searchable, since pasting a worktag from an email into the search box is the main use. These are internal administrative codes. GrantSerializer's field allowlist already excludes them; test_api.py now pins that, including a scan of the whole response body. Grant.grant_id is untouched: it is the *sponsor's* award ID (the NSF number), it is public, and it stays in the API. It gains a verbose_name of "Sponsor grant ID" so the two are distinguishable on the form. 2. A GrantTrackingLink model holding bookmarks to the official UW CSE and UW Award Portal trackers, rendered as a link bar atop the Grant changelist. These live in the database rather than in settings.py because the real URLs embed per-PI SharePoint sharing tokens and this repository is public; the URLField is max_length=1000 for the same reason (the SharePoint link blows past the 200-char default). Permissions: Editors (PhD students, staff) now hold view_grant so they can look up a worktag without pinging the PI, but nothing more. GrantAdmin hides the funding amounts, proposal PDFs/raw files, the total-funding rollup, and the tracking links from anyone who isn't a superuser, so the #1125 decision that funding data stays with the superuser still holds. GrantTrackingLink is superuser-only by the same mechanism as Award: absent from both group specs. Note that Django's read-only rendering resolves labels through admin.utils.label_for_field, which reads the model's verbose_name and ignores ModelAdmin form labels — hence the model-level verbose_names, which is what makes the labels correct on the read-only page Editors actually see. Version 2.34.0. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 1 + docs/ADMIN_USERS_AND_GROUPS.md | 16 +- docs/API.md | 6 + makeabilitylab/settings.py | 4 +- website/admin/__init__.py | 1 + website/admin/admin_site.py | 6 +- website/admin/grant_admin.py | 119 +++++++-- website/admin/grant_tracking_link_admin.py | 40 +++ website/api/serializers.py | 12 +- .../management/commands/setup_admin_groups.py | 16 +- website/models/__init__.py | 1 + website/models/grant.py | 34 ++- website/models/grant_tracking_link.py | 52 ++++ .../admin/website/grant/change_list.html | 98 +++++++- website/tests/test_api.py | 33 +++ website/tests/test_grant_tracking.py | 235 ++++++++++++++++++ website/tests/test_setup_admin_groups.py | 43 +++- 17 files changed, 664 insertions(+), 53 deletions(-) create mode 100644 website/admin/grant_tracking_link_admin.py create mode 100644 website/models/grant_tracking_link.py create mode 100644 website/tests/test_grant_tracking.py diff --git a/CLAUDE.md b/CLAUDE.md index b3d4e5eb..8f74b140 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,7 @@ Custom admin organization lives in `website/admin/admin_site.py` (`MakeabilityLa - A `Publication` is the central artifact. `Talk`, `Poster`, `Video` are related artifacts; the admin tip is to start from the Publication's edit page so shared fields (title, authors, date, venue) auto-fill on the children. - `Person` ↔ `Project` via `ProjectRole` (with start/end dates). The `auto_close_project_roles` management command (run on every container start) closes expired roles. - `Award` (separate from `Publication.award`) represents external recognitions; sectioned on the public Awards page by `AwardType`. Paper-level awards are NOT `Award` — they're on `Publication.award`. Keep this distinction in mind when modifying either. +- **Grant IDs come in two flavors (#1448) — don't conflate them.** `Grant.grant_id` is the *sponsor's* award ID (the NSF number); it is public and is serialized by the API. `Grant.uw_grant_id` (the UW/Workday grant worktag), `uw_award_number`, and `uw_award_name` are UW's *internal* administrative codes: never rendered publicly, deliberately absent from `GrantSerializer`'s field allowlist, and pinned that way by `test_api.py`. `GrantTrackingLink` holds admin-only bookmarks to the official UW CSE / UW Award Portal trackers, rendered atop the Grant changelist — they live in the DB, not in source, because the real URLs carry personal SharePoint sharing tokens and this repo is public. Grant is `view`-only for Editors (worktag lookup); `GrantAdmin.SUPERUSER_ONLY_FIELDS` hides funding amounts and proposal files from non-superusers. - Many M2M relations use `SortedManyToManyField` (vendored `sortedm2m` widget) so display order is editor-controlled, not alphabetical. ### URL routing quirks diff --git a/docs/ADMIN_USERS_AND_GROUPS.md b/docs/ADMIN_USERS_AND_GROUPS.md index d1cf53c8..c97c6904 100644 --- a/docs/ADMIN_USERS_AND_GROUPS.md +++ b/docs/ADMIN_USERS_AND_GROUPS.md @@ -41,6 +41,12 @@ These are defined declaratively in `banner, person, position, project, keyword, talk, publication, poster, news, video, photo, projectumbrella, sponsor, projectrole`. +Plus one **read-only** exception: `grant` gets `view` and nothing else, so PhD +students can look up a UW grant worktag / award number without pinging the PI. +`GrantAdmin` additionally hides the funding amounts, the proposal PDFs/raw files, +the total-funding rollup, and the official tracking links from anyone who isn't a +superuser — so "funding data stays with the superuser" still holds. + **`Contributors`** — submit-and-review, never destroy: - `person`: `add`, `change`, `view` (edit bios) - `publication`, `talk`, `poster`, `projectrole`: `add` + `view` (create their @@ -50,9 +56,13 @@ video, photo, projectumbrella, sponsor, projectrole`. ### Deliberately admin-only (neither group) -- **`Grant`** (Grants & Funding — funding data) and **`Award`** (curated external - recognitions). Note: *paper* awards live on `Publication.award`, which Editors - *can* edit via the publication; only the standalone `Award` model is withheld. +- **`Grant`** — *editing* only; Editors can view it read-only (see above), but + add/change/delete stay with the superuser, as do funding amounts and files. +- **`Award`** (curated external recognitions). Note: *paper* awards live on + `Publication.award`, which Editors *can* edit via the publication; only the + standalone `Award` model is withheld. +- **`GrantTrackingLink`** — bookmarks to the maintainer's UW CSE / UW Award + Portal financial-reporting pages, shown atop the Grant changelist. - `User`, `Group`, `Permission`, `LogEntry`, sessions — account/audit administration. ## How it's enforced diff --git a/docs/API.md b/docs/API.md index 619f32f5..79935c9e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -102,6 +102,12 @@ Filters: `?project=`, `?sponsor=`. Each grant includes its `sponsor`, `grant_id`, `grant_url`, and the `projects` it funds. Funding amounts are intentionally **not** exposed by the API. +> **Note:** `grant_id` is the *sponsor's* award ID (e.g. the NSF award number), +> which is already public. UW's own tracking codes — `uw_grant_id` (the Workday +> grant worktag), `uw_award_number`, and `uw_award_name` — are internal +> administrative data and are intentionally **not** exposed, like `funding_amount` +> and `email`. + ### People — `GET /api/v1/people/` Actual lab members (people with at least one Position); external co-authors are diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py index bf5c2d82..4242561f 100644 --- a/makeabilitylab/settings.py +++ b/makeabilitylab/settings.py @@ -87,8 +87,8 @@ SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # Makeability Lab Global Variables, including Makeability Lab version -ML_WEBSITE_VERSION = "2.33.0" # Keep this updated with each release and also change the short description below -ML_WEBSITE_VERSION_DESCRIPTION = "The database now writes a nightly pg_dump into its own volume, so the infrastructure team's snapshots always contain a consistent restore point. Backup health shows on this dashboard and /version.json (#1443)." +ML_WEBSITE_VERSION = "2.34.0" # Keep this updated with each release and also change the short description below +ML_WEBSITE_VERSION_DESCRIPTION = "Grants now record UW's internal tracking codes — the grant worktag, award number, and award name — and Editors can view grants read-only to look one up. Funding amounts, proposal files, and the links to the official UW trackers stay superuser-only (#1448)." 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/admin/__init__.py b/website/admin/__init__.py index 5980c694..1cb31a9f 100644 --- a/website/admin/__init__.py +++ b/website/admin/__init__.py @@ -55,6 +55,7 @@ award_admin, banner_admin, grant_admin, + grant_tracking_link_admin, keyword_admin, logentry_admin, news_admin, diff --git a/website/admin/admin_site.py b/website/admin/admin_site.py index 9b7a483d..477e6eaf 100644 --- a/website/admin/admin_site.py +++ b/website/admin/admin_site.py @@ -65,8 +65,10 @@ class MakeabilityLabAdminSite(admin.AdminSite): ), ( "Grants & Funding", - ["Grant", "Sponsor"], - "Generally, Jon will handle these. Please contact him if you think you need to edit." + ["Grant", "Sponsor", "GrantTrackingLink"], + "Generally, Jon will handle these. Please contact him if you think you need to edit. " + "Grants are readable (but not editable) by Editors so you can look up a UW grant " + "worktag or award number when purchasing, traveling, or filing an appointment." ), ( "Configuration", diff --git a/website/admin/grant_admin.py b/website/admin/grant_admin.py index 7d6f8f93..be9cfc1d 100644 --- a/website/admin/grant_admin.py +++ b/website/admin/grant_admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from website.models import Grant +from website.models import Grant, GrantTrackingLink from django.db.models import Sum from website.admin import ArtifactAdmin from website.admin.admin_site import ml_admin_site @@ -7,16 +7,28 @@ @admin.register(Grant, site=ml_admin_site) class GrantAdmin(ArtifactAdmin): + # Fields and columns only the superuser may see (#1448). Editors (PhD + # students / staff) hold `view_grant` so they can look up a UW worktag, but + # funding data and the proposal files were the reason Grant was superuser-only + # in the first place (#1125), so those stay hidden. Enforced in + # get_fieldsets / get_list_display / changelist_view below, and pinned by + # website/tests/test_grant_tracking.py. + SUPERUSER_ONLY_FIELDS = ('funding_amount', 'pdf_file', 'raw_file') + # search_fields are used for auto-complete, see: # https://docs.djangoproject.com/en/3.0/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields # Dropped 'date' (string-searching a DateField is unhelpful); added PI/Co-PI # (author) and sponsor name so grants are findable by people and funder. + # The UW codes are searchable too — pasting a worktag from an email into the + # search box is the main way this page gets used (#1448). search_fields = ['title', 'forum_name', 'authors__first_name', - 'authors__last_name', 'sponsor__name'] + 'authors__last_name', 'sponsor__name', + 'uw_grant_id', 'uw_award_number', 'uw_award_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 - list_display = ('title', 'date', 'get_first_author_last_name', 'sponsor', 'funding_amount') + list_display = ('title', 'date', 'get_first_author_last_name', 'sponsor', + 'uw_grant_id', 'funding_amount') # I want to make sponsor auto-complete but it's causing errors, so commenting out # https://github.com/makeabilitylab/makeabilitylabwebsite/issues/1093 @@ -37,58 +49,117 @@ def get_queryset(self, request): fieldsets = [ (None, {'fields': ['title', 'authors']}), ('Grant Info', {'fields': ['date', 'end_date', 'sponsor', 'funding_amount', 'forum_url', 'grant_id']}), + ('UW Internal Tracking', {'fields': ['uw_grant_id', 'uw_award_number', 'uw_award_name'], + 'description': 'UW/Workday administrative codes for this award. ' + 'These are internal: they are never shown on the ' + 'public site and are deliberately excluded from the REST API.'}), ('Grant Files', {'fields': ['pdf_file', 'raw_file']}), ('Project Info', {'fields': ['projects', 'project_umbrellas']}), ('Keyword Info', {'fields': ['keywords']}), ] + def get_fieldsets(self, request, obj=None): + """Drop the funding/file fields for non-superusers. + + Editors get `view_grant` only, so Django already renders this form + read-only; this narrows *what* they can read. Any section left empty + (i.e. 'Grant Files') disappears entirely rather than rendering a header + with nothing under it. + """ + fieldsets = super().get_fieldsets(request, obj) + if request.user.is_superuser: + return fieldsets + + visible = [] + for name, options in fieldsets: + fields = [f for f in options['fields'] + if f not in self.SUPERUSER_ONLY_FIELDS] + if fields: + # New dict per request — never mutate the class-level fieldsets. + visible.append((name, {**options, 'fields': fields})) + return visible + + def get_list_display(self, request): + """Same boundary as get_fieldsets, applied to the changelist columns.""" + list_display = super().get_list_display(request) + if request.user.is_superuser: + return list_display + return tuple(column for column in list_display + if column not in self.SUPERUSER_ONLY_FIELDS) + def changelist_view(self, request, extra_context=None): """ - Override the changelist view to include total funding amount. - - This calculates the sum of all funding_amount values and passes it - to the template context for display at the top of the grants list. + Override the changelist view to include total funding amount and the + official UW tracking links. + + Both are superuser-only: the funding rollup is the aggregate of the data + we hide per-row from Editors, and the tracking links point at the + maintainer's personal financial-reporting systems (#1448). """ # Get the base queryset (respects any active filters) response = super().changelist_view(request, extra_context) - + # Only proceed if we have a context (not a redirect response) - if hasattr(response, 'context_data'): + if hasattr(response, 'context_data') and request.user.is_superuser: # Get the filtered queryset from the changelist cl = response.context_data.get('cl') if cl: queryset = cl.queryset else: queryset = self.get_queryset(request) - + # Calculate total funding from the (possibly filtered) queryset total = queryset.aggregate( total_funding=Sum('funding_amount') )['total_funding'] or 0 - + response.context_data['total_funding'] = total - + response.context_data['grant_tracking_links'] = GrantTrackingLink.objects.all() + return response def get_form(self, request, obj=None, **kwargs): form = super().get_form(request, obj, **kwargs) - form.base_fields['authors'].label = 'PIs and Co-PIs' - form.base_fields['authors'].help_text = "The first author is assumed to be the PI. Co-PIs should be listed in the order they appear on the grant." + def tweak(field_name, **attrs): + """Apply label/help_text overrides to a field if this form has it. - form.base_fields['date'].label = 'Start date' - form.base_fields['date'].help_text = 'Start date for the grant' + Non-superusers get a reduced fieldset (see get_fieldsets), so the + funding and file fields are simply absent from their form — look + them up defensively rather than KeyError-ing on a view-only render. + """ + field = form.base_fields.get(field_name) + if field is None: + return + for attr, value in attrs.items(): + setattr(field, attr, value) + + tweak('authors', + label='PIs and Co-PIs', + help_text="The first author is assumed to be the PI. Co-PIs should be listed in the order they appear on the grant.") + + tweak('date', label='Start date', help_text='Start date for the grant') - form.base_fields['forum_url'].label = 'Grant url' grant_url = "https://www.nsf.gov/awardsearch/showAward?AWD_ID=1302338" - form.base_fields['forum_url'].help_text = f'The grant url (e.g., {grant_url})' + tweak('forum_url', + label='Grant url', + help_text=f'The grant url (e.g., {grant_url})') + + # NB: 'grant_id' is disambiguated from 'UW Grant ID (worktag)' by a + # verbose_name on the model, not here — a label set on the form is + # ignored when Django renders the read-only view Editors get. - form.base_fields['pdf_file'].label = 'Grant PDF' - form.base_fields['pdf_file'].help_text = 'The rendered PDF of the grant. Internal only. This is not currently shown on the website.' - form.base_fields['raw_file'].help_text = 'The raw file (e.g., Word Docx, Overleaf Zip, etc.) for archival purposes. This is not shown on the website.' + tweak('pdf_file', + label='Grant PDF', + help_text='The rendered PDF of the grant. Internal only. This is not currently shown on the website.') + tweak('raw_file', + help_text='The raw file (e.g., Word Docx, Overleaf Zip, etc.) for archival purposes. This is not shown on the website.') - form.base_fields['projects'].help_text = 'Associate this grant with all the projects that it supports.' + tweak('projects', + help_text='Associate this grant with all the projects that it supports.') - form.base_fields['funding_amount'].widget.attrs['style'] = f'min-width: 300px;' + funding_amount = form.base_fields.get('funding_amount') + if funding_amount is not None: + funding_amount.widget.attrs['style'] = 'min-width: 300px;' - return form \ No newline at end of file + return form diff --git a/website/admin/grant_tracking_link_admin.py b/website/admin/grant_tracking_link_admin.py new file mode 100644 index 00000000..a02f1fab --- /dev/null +++ b/website/admin/grant_tracking_link_admin.py @@ -0,0 +1,40 @@ +from django.contrib import admin +from django.utils.html import format_html + +from website.models import GrantTrackingLink +from website.admin.admin_site import ml_admin_site + + +@admin.register(GrantTrackingLink, site=ml_admin_site) +class GrantTrackingLinkAdmin(admin.ModelAdmin): + """ + Admin for the official grant-tracking bookmarks shown atop the Grant + changelist (#1448). + + Superuser-only, by the same mechanism as Grant and Award: this model is + absent from EDITORS_MODELS / CONTRIBUTORS_SPEC in the setup_admin_groups + management command, so neither group is ever granted its permissions. + """ + + list_display = ('label', 'link', 'notes', 'display_order') + list_editable = ('display_order',) + ordering = ('display_order', 'label') + + fieldsets = [ + (None, { + 'fields': ['label', 'url', 'notes', 'display_order'], + 'description': 'Links to the official UW systems that track our grants ' + '(UW CSE financial reporting, the UW Award Portal, ...). ' + 'They are shown at the top of the Grants page, to superusers only. ' + 'These are stored here rather than in the code because the URLs can ' + 'contain personal sharing tokens and this repository is public.', + }), + ] + + @admin.display(description='Link') + def link(self, obj): + """Clickable, truncated URL — SharePoint URLs are long enough to blow out + the changelist column otherwise.""" + display = obj.url if len(obj.url) <= 80 else f"{obj.url[:80]}…" + return format_html('{}', + obj.url, display) diff --git a/website/api/serializers.py b/website/api/serializers.py index 45869ce1..5dc11a01 100644 --- a/website/api/serializers.py +++ b/website/api/serializers.py @@ -240,7 +240,17 @@ class SponsorSummarySerializer(serializers.Serializer): class GrantSerializer(serializers.ModelSerializer): """A funding grant. ``start_date`` and ``grant_url`` are model properties - aliasing the shared Artifact ``date`` / ``forum_url`` fields.""" + aliasing the shared Artifact ``date`` / ``forum_url`` fields. + + ``fields`` below is an explicit allowlist, and deliberately so: ``Grant`` also + carries UW's internal Workday codes (``uw_grant_id`` — the grant worktag — + plus ``uw_award_number`` / ``uw_award_name``) and ``funding_amount``. Those are + internal administrative data and must never be published here, the same way + ``Person.email`` is withheld from the people endpoints. Do not switch this to + ``exclude`` or ``__all__``; ``test_api.py`` pins the omission (#1448). + + Note that ``grant_id`` IS public — it is the *sponsor's* award ID (the NSF + number), not UW's.""" sponsor = SponsorSummarySerializer(read_only=True) grant_url = serializers.URLField(read_only=True) diff --git a/website/management/commands/setup_admin_groups.py b/website/management/commands/setup_admin_groups.py index 42ba137d..d08c4bc3 100644 --- a/website/management/commands/setup_admin_groups.py +++ b/website/management/commands/setup_admin_groups.py @@ -22,8 +22,11 @@ # Editors — PhD students and long-term staff who maintain the site. Full content # management on the public-facing models. DELIBERATELY EXCLUDES: -# - grant, award -> admin-only by decision (funding data / curated -# external recognitions stay with the superuser) +# - award -> admin-only by decision (curated external +# recognitions stay with the superuser) +# - grant -> VIEW ONLY, see EDITORS_SPEC below (#1448) +# - granttrackinglink -> admin-only: bookmarks to the maintainer's own +# financial-reporting systems # - user/group/permission/logentry/session -> no account administration # outside the superuser # - contenttype, easy_thumbnails.* -> infra/cache tables nobody hand-edits @@ -35,6 +38,15 @@ ] EDITORS_SPEC = {("website", model): CRUD for model in EDITORS_MODELS} +# The one read-only entry (#1448). UW identifies every award by a "grant worktag" +# (GR…), an award number, and an award name, and sponsored-programs staff ask for +# them constantly — so PhD students need to be able to look one up without +# pinging the PI. `view` only: they cannot add, edit, or delete a grant, and +# GrantAdmin additionally hides the funding amounts, the proposal PDFs, and the +# total-funding rollup from anyone who isn't a superuser. That keeps the #1125 +# decision ("funding data stays with the superuser") intact. +EDITORS_SPEC[("website", "grant")] = ("view",) + # Contributors — undergrads / interns (shared `contributor` account, or a personal # account promoted to Editors if they become a regular maintainer). Narrowest # useful tier: edit bios, and add (with view) on the main artifacts so they can diff --git a/website/models/__init__.py b/website/models/__init__.py index 7a1b16a2..06165eb0 100644 --- a/website/models/__init__.py +++ b/website/models/__init__.py @@ -18,4 +18,5 @@ from .talk import TalkType from .video import Video from .grant import Grant +from .grant_tracking_link import GrantTrackingLink from .award import Award, AwardType \ No newline at end of file diff --git a/website/models/grant.py b/website/models/grant.py index 78a662ac..96715d97 100644 --- a/website/models/grant.py +++ b/website/models/grant.py @@ -28,8 +28,38 @@ class Grant(Artifact): funding_amount = models.IntegerField(null=True) funding_amount.help_text = "Amount of funding (in USD) for this grant" - grant_id = models.CharField(max_length=255, null=True, blank=True) - grant_id.help_text = "The grant id (e.g., 1302338)" + # verbose_name (rather than only an admin form label) so the "sponsor's vs + # UW's" distinction also shows on the *read-only* change form Editors see: + # Django's readonly rendering reads the model's verbose_name and ignores + # ModelAdmin form label overrides (django.contrib.admin.utils.label_for_field). + grant_id = models.CharField(verbose_name="Sponsor grant ID", + max_length=255, null=True, blank=True) + grant_id.help_text = "The sponsor's own ID for this grant (e.g., 1302338). This one is public." + + # --- UW internal tracking (#1448) --------------------------------------- + # Careful: `grant_id` above is the *sponsor's* award ID (the NSF number). It + # is public information and is serialized by the REST API. The three fields + # below are UW/Workday's own administrative codes for the same award. They + # are INTERNAL: deliberately absent from GrantSerializer's field allowlist + # (see website/api/serializers.py) and never rendered on a public page. + # Non-superusers can *view* them in the admin — that's the point, sponsored- + # programs staff ask for the worktag constantly — but not the funding data + # or proposal files alongside them (see GrantAdmin.get_fieldsets). + + uw_grant_id = models.CharField(verbose_name="UW Grant ID (worktag)", + max_length=255, null=True, blank=True) + uw_grant_id.help_text = ("UW's internal grant worktag, e.g. GR012345. If the award " + "spans several worktags, list them comma-separated. " + "Internal only — never shown publicly or in the REST API.") + + uw_award_number = models.CharField(verbose_name="UW Award number", + max_length=255, null=True, blank=True) + uw_award_number.help_text = "UW's award number for this grant, e.g. AWD-00012345. Internal only." + + uw_award_name = models.CharField(verbose_name="UW Award name", + max_length=512, null=True, blank=True) + uw_award_name.help_text = ("The award name as UW records it, which is often not the " + "title above. Internal only.") @property def start_date(self): diff --git a/website/models/grant_tracking_link.py b/website/models/grant_tracking_link.py new file mode 100644 index 00000000..080eaa44 --- /dev/null +++ b/website/models/grant_tracking_link.py @@ -0,0 +1,52 @@ +from django.db import models + + +class GrantTrackingLink(models.Model): + """ + A superuser-only bookmark to an official grant/finance tracking system — + e.g. UW CSE's SharePoint "Financial Reporting and Projections" folder, or + the UW-wide Award Portal. Rendered as a link bar at the top of the Grant + changelist (see ``website/templates/admin/website/grant/change_list.html``). + + Why these live in the database instead of ``settings.py`` (#1448): the real + URLs embed per-PI SharePoint sharing tokens and query parameters, and this + repository is public. Keeping them as editable rows means nothing sensitive + is ever committed — the maintainer pastes them in through ``/admin`` on each + environment. + + Access is superuser-only by the same mechanism as ``Grant`` and ``Award``: + the model is simply absent from ``EDITORS_MODELS``/``CONTRIBUTORS_SPEC`` in + the ``setup_admin_groups`` management command. + + Usage:: + + GrantTrackingLink.objects.create( + label="UW CSE — Financial Reporting and Projections", + url="https://uwnetid.sharepoint.com/sites/...", + notes="Requires UW NetID sign-in", + display_order=0, + ) + """ + + label = models.CharField(max_length=255) + label.help_text = "Link text shown on the Grants page (e.g., 'UW Award Portal')" + + # NOT the URLField default of 200: the UW CSE SharePoint link runs well past + # that once its folder id and sharing parameters are included. Postgres would + # reject the insert outright. Pinned by a regression test. + url = models.URLField(max_length=1000) + url.help_text = "Full URL of the tracking system. May be very long (SharePoint links are)." + + notes = models.CharField(max_length=500, null=True, blank=True) + notes.help_text = "Optional reminder shown next to the link (e.g., 'Requires UW NetID')" + + display_order = models.IntegerField(default=0) + display_order.help_text = "Lower numbers appear first." + + class Meta: + ordering = ['display_order', 'label'] + verbose_name = "grant tracking link" + verbose_name_plural = "grant tracking links" + + def __str__(self): + return self.label diff --git a/website/templates/admin/website/grant/change_list.html b/website/templates/admin/website/grant/change_list.html index 2f3be5cf..837f90a6 100644 --- a/website/templates/admin/website/grant/change_list.html +++ b/website/templates/admin/website/grant/change_list.html @@ -1,19 +1,91 @@ {% extends "admin/change_list.html" %} {% load humanize %} +{% comment %} + Grant changelist additions. + + Everything in this block is superuser-only. Editors hold `view_grant` so they + can look up a UW worktag (#1448), but the funding rollup and the official + tracking links stay with the superuser — GrantAdmin.changelist_view only puts + `total_funding` / `grant_tracking_links` into the context for superusers, and + the guard below is a second line of defense. +{% endcomment %} {% block result_list %} - {# Display total funding summary above the results table #} -
- - Total Funding: ${{ total_funding|intcomma }} - - {% if cl.result_count != cl.full_result_count %} - - (filtered from {{ cl.full_result_count }} total grants) - - {% endif %} -
- + {% if request.user.is_superuser %} +
+ {# Official UW systems of record. These are rows in the DB (Grants & Funding > #} + {# Grant tracking links), not hard-coded, because the real URLs carry personal #} + {# sharing tokens and this repo is public. #} + {% if grant_tracking_links %} + + {% else %} + + {% endif %} + + Total Funding: ${{ total_funding|intcomma }} + {% if cl.result_count != cl.full_result_count %} + + (filtered from {{ cl.full_result_count }} total grants) + + {% endif %} +
+ {% endif %} + + {{ block.super }} +{% endblock %} + +{% block extrastyle %} {{ block.super }} -{% endblock %} \ No newline at end of file + +{% endblock %} diff --git a/website/tests/test_api.py b/website/tests/test_api.py index 23887476..74d8d864 100644 --- a/website/tests/test_api.py +++ b/website/tests/test_api.py @@ -127,6 +127,11 @@ def setUp(self): date=date(2015, 1, 1), funding_amount=500000, grant_id="1302338", + # UW's internal Workday codes (#1448). Populated here on purpose: the + # tests below assert they never reach the public payload. + uw_grant_id="GR012345", + uw_award_number="AWD-00012345", + uw_award_name="Internal UW Award Name", ) self.grant.projects.add(self.project) @@ -214,6 +219,34 @@ def test_project_grants_subresource(self): self.assertEqual(grant["sponsor"]["short_name"], "NSF") # Funding amounts are intentionally not exposed by the public API. self.assertNotIn("funding_amount", grant) + self._assert_no_internal_uw_fields(grant) + + # ---- grants: the internal/public boundary (#1448) ------------------------ + + # UW's Workday codes are internal administrative data. GrantSerializer uses an + # explicit field allowlist so they're excluded structurally — these tests keep + # it that way if someone later switches to `exclude` or `fields = "__all__"`. + INTERNAL_UW_FIELDS = ("uw_grant_id", "uw_award_number", "uw_award_name") + + def _assert_no_internal_uw_fields(self, grant): + for field in self.INTERNAL_UW_FIELDS: + self.assertNotIn(field, grant) + + def test_grants_list_omits_internal_uw_fields(self): + resp = self.client.get("/api/v1/grants/") + self.assertEqual(resp.status_code, 200) + grant = resp.json()["results"][0] + # The sponsor-side award ID stays public — it's the NSF number. + self.assertEqual(grant["grant_id"], "1302338") + self._assert_no_internal_uw_fields(grant) + + def test_grants_list_body_never_contains_a_worktag(self): + """Belt-and-braces: the worktag must not leak through any nested + serializer or added field either, so scan the whole response body.""" + body = self.client.get("/api/v1/grants/").content.decode() + self.assertNotIn("GR012345", body) + self.assertNotIn("AWD-00012345", body) + self.assertNotIn("Internal UW Award Name", body) def test_project_people_subresource(self): resp = self.client.get("/api/v1/projects/projectsidewalk/people/") diff --git a/website/tests/test_grant_tracking.py b/website/tests/test_grant_tracking.py new file mode 100644 index 00000000..00d3e682 --- /dev/null +++ b/website/tests/test_grant_tracking.py @@ -0,0 +1,235 @@ +""" +Tests for UW grant tracking (#1448). + +Two features, one shared privacy boundary: + +1. ``Grant.uw_grant_id`` / ``uw_award_number`` / ``uw_award_name`` — UW's internal + Workday codes. Sponsored-programs staff ask for the worktag constantly, so PhD + students (the ``Editors`` group) can now *view* grants to look one up. They must + NOT see the funding data or proposal files that were the reason Grant was + superuser-only in the first place (#1125). + +2. ``GrantTrackingLink`` — superuser-only bookmarks to the official UW CSE and UW + Award Portal trackers, rendered atop the Grant changelist. These live in the DB + rather than in source because the real URLs embed per-PI SharePoint sharing + tokens and this repository is public. + +The API-side half of the boundary (the ``uw_*`` fields must never be serialized) +is pinned in ``test_api.py``. +""" + +from datetime import date + +from django.contrib.auth.models import Group, User +from django.core.management import call_command + +from website.models import Grant, GrantTrackingLink, Sponsor +from website.tests.base import DatabaseTestCase + +CHANGELIST_URL = "/admin/website/grant/" +TRACKING_LINK_URL = "/admin/website/granttrackinglink/" + +# A stand-in for the real UW CSE SharePoint link: those carry a pile of query +# parameters and run well past Django's 200-char URLField default. Built here +# rather than hard-coded so no real sharing token lands in the repo. +LONG_SHAREPOINT_URL = ( + "https://example.sharepoint.com/sites/cse_research_administration/" + "Shared%20Documents/Forms/AllItems.aspx?" + + "&".join(f"param{i}=value{i}value{i}" for i in range(20)) +) + + +class GrantUwFieldTests(DatabaseTestCase): + """The three UW fields are optional and round-trip unchanged.""" + + def setUp(self): + self.sponsor = Sponsor.objects.create(name="National Science Foundation", + short_name="NSF") + + def test_uw_fields_are_optional(self): + """Every existing grant predates these fields, so blank must be legal — + both in the database (null) and in the admin form (blank).""" + grant = Grant.objects.create(title="No UW codes yet", sponsor=self.sponsor, + date=date(2015, 1, 1)) + grant.refresh_from_db() + self.assertIsNone(grant.uw_grant_id) + self.assertIsNone(grant.uw_award_number) + self.assertIsNone(grant.uw_award_name) + + for name in ("uw_grant_id", "uw_award_number", "uw_award_name"): + field = Grant._meta.get_field(name) + self.assertTrue(field.null, f"{name} must be null=True") + self.assertTrue(field.blank, f"{name} must be blank=True") + + def test_uw_fields_round_trip(self): + grant = Grant.objects.create( + title="Funded thing", sponsor=self.sponsor, date=date(2020, 1, 1), + uw_grant_id="GR012345, GR012346", # one award can span several worktags + uw_award_number="AWD-00012345", + uw_award_name="Accessible Sidewalks: A Really Long UW Award Name", + ) + grant.refresh_from_db() + self.assertEqual(grant.uw_grant_id, "GR012345, GR012346") + self.assertEqual(grant.uw_award_number, "AWD-00012345") + self.assertEqual(grant.uw_award_name, + "Accessible Sidewalks: A Really Long UW Award Name") + + def test_sponsor_grant_id_is_untouched(self): + """``grant_id`` still means the *sponsor's* award ID (NSF-style) and is + public. The UW worktag is a separate, internal field — regression guard + against anyone collapsing the two.""" + grant = Grant.objects.create(title="Both IDs", sponsor=self.sponsor, + date=date(2020, 1, 1), + grant_id="1302338", uw_grant_id="GR012345") + grant.refresh_from_db() + self.assertEqual(grant.grant_id, "1302338") + self.assertEqual(grant.uw_grant_id, "GR012345") + + +class GrantTrackingLinkModelTests(DatabaseTestCase): + + def test_long_sharepoint_url_round_trips(self): + """Pins ``max_length=1000`` on the URLField. Django's 200-char default + would reject the real UW CSE SharePoint link outright (Postgres raises on + save; full_clean raises before that).""" + self.assertGreater(len(LONG_SHAREPOINT_URL), 200) + link = GrantTrackingLink(label="UW CSE Financial Reporting", + url=LONG_SHAREPOINT_URL) + link.full_clean() + link.save() + link.refresh_from_db() + self.assertEqual(link.url, LONG_SHAREPOINT_URL) + + def test_default_ordering_is_display_order_then_label(self): + GrantTrackingLink.objects.create(label="Zebra", url="https://z.example.com", + display_order=1) + GrantTrackingLink.objects.create(label="Apple", url="https://a.example.com", + display_order=1) + GrantTrackingLink.objects.create(label="First", url="https://f.example.com", + display_order=0) + self.assertEqual([l.label for l in GrantTrackingLink.objects.all()], + ["First", "Apple", "Zebra"]) + + def test_str_is_the_label(self): + link = GrantTrackingLink.objects.create(label="UW Award Portal", + url="https://example.com") + self.assertEqual(str(link), "UW Award Portal") + + +class GrantAdminAccessTests(DatabaseTestCase): + """Who sees what on the Grant admin pages. + + The rule: Editors may *look up a worktag*; everything that made Grant + superuser-only (funding amounts, proposal PDFs, the total-funding rollup, the + tracking-link bookmarks) stays with the superuser. + """ + + def setUp(self): + call_command("setup_admin_groups") + + self.sponsor = Sponsor.objects.create(name="National Science Foundation", + short_name="NSF") + self.grant = Grant.objects.create( + title="NSF Award for Sidewalk", sponsor=self.sponsor, + date=date(2015, 1, 1), funding_amount=1234567, + grant_id="1302338", uw_grant_id="GR012345", + uw_award_number="AWD-00012345", uw_award_name="Sidewalk Award", + ) + GrantTrackingLink.objects.create(label="UW CSE Financial Reporting", + url=LONG_SHAREPOINT_URL, display_order=0) + GrantTrackingLink.objects.create(label="UW Award Portal", + url="https://example.finance.uw.edu/fin/AwardPortal", + display_order=1) + + self.superuser = User.objects.create_superuser("boss", "boss@example.com", "x") + self.editor = User.objects.create_user("ed", is_staff=True) + self.editor.groups.add(Group.objects.get(name="Editors")) + self.contributor = User.objects.create_user("intern", is_staff=True) + self.contributor.groups.add(Group.objects.get(name="Contributors")) + + # ---- superuser ---------------------------------------------------------- + + def test_superuser_sees_tracking_links_and_funding(self): + self.client.force_login(self.superuser) + content = self.client.get(CHANGELIST_URL).content.decode() + self.assertIn("UW CSE Financial Reporting", content) + self.assertIn("UW Award Portal", content) + self.assertIn("Total Funding", content) + self.assertIn("1,234,567", content) + self.assertIn("GR012345", content) + + def test_tracking_links_render_in_display_order(self): + self.client.force_login(self.superuser) + content = self.client.get(CHANGELIST_URL).content.decode() + self.assertLess(content.index("UW CSE Financial Reporting"), + content.index("UW Award Portal")) + + def test_superuser_change_form_has_all_fields(self): + self.client.force_login(self.superuser) + content = self.client.get(f"{CHANGELIST_URL}{self.grant.pk}/change/").content.decode() + self.assertIn('name="funding_amount"', content) + self.assertIn('name="pdf_file"', content) + self.assertIn('name="uw_grant_id"', content) + + # ---- Editors (PhD students): read-only worktag lookup -------------------- + + def test_editor_can_view_changelist_and_see_worktag(self): + self.client.force_login(self.editor) + resp = self.client.get(CHANGELIST_URL) + self.assertEqual(resp.status_code, 200) + self.assertIn("GR012345", resp.content.decode()) + + def test_editor_does_not_see_tracking_links_or_funding_totals(self): + self.client.force_login(self.editor) + content = self.client.get(CHANGELIST_URL).content.decode() + self.assertNotIn("UW CSE Financial Reporting", content) + self.assertNotIn("UW Award Portal", content) + self.assertNotIn("Total Funding", content) + self.assertNotIn("1,234,567", content) + + def test_editor_change_form_hides_funding_and_files_and_is_readonly(self): + self.client.force_login(self.editor) + content = self.client.get( + f"{CHANGELIST_URL}{self.grant.pk}/change/").content.decode() + self.assertIn("GR012345", content) # the whole point + self.assertIn("AWD-00012345", content) + self.assertNotIn("funding_amount", content) + self.assertNotIn("pdf_file", content) + self.assertNotIn("raw_file", content) + self.assertNotIn("1234567", content) + # View-only permission => Django renders no save buttons at all. + self.assertNotIn('name="_save"', content) + + def test_editor_readonly_form_labels_disambiguate_the_two_grant_ids(self): + """The read-only form Editors see must still distinguish the sponsor's + award ID from UW's worktag. + + Django's readonly rendering resolves labels through + ``admin.utils.label_for_field``, which reads the *model's* verbose_name + and ignores any label set on the ModelAdmin form — so these labels only + work because they're declared on the model fields. Regression guard for + anyone who moves them back into GrantAdmin.get_form. + """ + self.client.force_login(self.editor) + content = self.client.get( + f"{CHANGELIST_URL}{self.grant.pk}/change/").content.decode() + self.assertIn("Sponsor grant ID", content) + self.assertIn("UW Grant ID (worktag)", content) + + def test_editor_cannot_add_or_delete_grants(self): + self.client.force_login(self.editor) + self.assertEqual(self.client.get(f"{CHANGELIST_URL}add/").status_code, 403) + self.assertEqual( + self.client.get(f"{CHANGELIST_URL}{self.grant.pk}/delete/").status_code, 403) + + def test_editor_cannot_reach_the_tracking_links_themselves(self): + """The bookmarks are the superuser's own financial-reporting links.""" + self.client.force_login(self.editor) + self.assertEqual(self.client.get(TRACKING_LINK_URL).status_code, 403) + + # ---- Contributors (ugrads): no grant access at all ----------------------- + + def test_contributor_cannot_view_grants(self): + self.client.force_login(self.contributor) + self.assertEqual(self.client.get(CHANGELIST_URL).status_code, 403) + self.assertEqual(self.client.get(TRACKING_LINK_URL).status_code, 403) diff --git a/website/tests/test_setup_admin_groups.py b/website/tests/test_setup_admin_groups.py index 78ed83d9..5922ec5f 100644 --- a/website/tests/test_setup_admin_groups.py +++ b/website/tests/test_setup_admin_groups.py @@ -7,7 +7,8 @@ Contributors groups so a future model rename/removal, or an accidental edit to the spec, can't silently widen or drop a group's access. They also assert the design's security boundaries: neither group can manage users/groups - (account admin stays superuser-only), nor touch Grant or Award (admin-only). + (account admin stays superuser-only), Award stays admin-only, and Grant is + view-only for Editors (#1448 — worktag lookup, nothing more). 2. SetupAdminGroupsSafetyTests + DockerEntrypointWiringTests cover the anti-lockout invariants. The command runs on EVERY container start, so it @@ -49,6 +50,11 @@ def _codenames(group_name): f"website.{action}_{model}" for model in EDITORS_MODELS for action in ("add", "change", "delete", "view") +} | { + # The one view-only entry (#1448): PhD students look up UW grant worktags, + # but funding data and proposal files stay with the superuser (GrantAdmin + # hides those fields from non-superusers). + "website.view_grant", } EXPECTED_CONTRIBUTORS = { @@ -83,14 +89,39 @@ def test_neither_group_can_administer_accounts(self): self.assertNotIn("auth.", codename, f"{group} has {codename}") self.assertNotIn("logentry", codename, f"{group} has {codename}") - def test_grant_and_award_are_admin_only(self): - """Grant (funding) and Award (external recognitions) stay superuser-only.""" + def test_award_is_admin_only(self): + """Award (curated external recognitions) stays superuser-only.""" call_command("setup_admin_groups") for group in ("Editors", "Contributors"): for codename in _codenames(group): - self.assertNotIn("_grant", codename, f"{group} has {codename}") self.assertNotIn("_award", codename, f"{group} has {codename}") + def test_grant_is_view_only_for_editors_and_hidden_from_contributors(self): + """Editors may look up a UW worktag and nothing more (#1448): exactly + ``view_grant``, never add/change/delete. Contributors get nothing. + + Checked with exact codenames rather than a substring: 'add_grant' and + 'add_granttrackinglink' both contain '_grant'.""" + call_command("setup_admin_groups") + self.assertEqual( + {c for c in _codenames("Editors") if c.endswith("_grant")}, + {"website.view_grant"}, + ) + self.assertEqual( + {c for c in _codenames("Contributors") if c.endswith("_grant")}, set() + ) + + def test_grant_tracking_links_are_admin_only(self): + """The bookmark bar points at the superuser's own financial-reporting + systems — neither group may see or edit it (#1448).""" + call_command("setup_admin_groups") + for group in ("Editors", "Contributors"): + self.assertEqual( + {c for c in _codenames(group) if c.endswith("_granttrackinglink")}, + set(), + f"{group} can reach grant tracking links", + ) + def test_contributors_cannot_delete_anything(self): call_command("setup_admin_groups") for codename in _codenames("Contributors"): @@ -179,8 +210,12 @@ def test_editor_effective_permissions_match_spec(self): self.assertTrue(ed.has_perm("website.change_publication")) self.assertTrue(ed.has_perm("website.delete_talk")) + # Grants are readable so a PhD student can look up a UW worktag (#1448). + self.assertTrue(ed.has_perm("website.view_grant")) # ...but not the admin-only or account-admin perms. + self.assertFalse(ed.has_perm("website.change_grant")) self.assertFalse(ed.has_perm("website.delete_grant")) + self.assertFalse(ed.has_perm("website.view_granttrackinglink")) self.assertFalse(ed.has_perm("website.add_award")) self.assertFalse(ed.has_perm("auth.add_user")) self.assertFalse(ed.has_perm("auth.change_group")) From 1662dfe6a998b2c6adfa7299ea1dd644cfe0e2dc Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Wed, 12 Aug 2026 16:00:57 -0700 Subject: [PATCH 2/3] Refine the grant changelist columns and theme the panel (#1448) From QA on localhost: - Reorder the changelist so the worktag sits right beside the title, which is what people open this page for: Title, UW Grant ID (worktag), Sponsor, Date. Superusers additionally get First Author (Last Name) and Funding Amount. - Make the Editor column set an allowlist (EDITOR_LIST_DISPLAY) rather than the superuser list with sensitive columns filtered out, so a column added for superusers later cannot leak into the PhD-student view by omission. - Prefetch authors only when the first-author column is actually rendered. The prefetch exists to keep that column from firing a query per row (#1346); Editors don't get the column, so they shouldn't pay for the join. - Color the tracking-links/funding panel from the admin's own CSS custom properties instead of literal hex, so it follows the light/dark theme toggle. The hard-coded #f8f9fa/#ddd rendered as a light box with light text in dark mode (this predates the branch; the panel just grew enough to make it show). Also verified against the real UW CSE SharePoint URL: 425 characters, which passes URLValidator and needs the max_length=1000 field (Django's 200-char default would reject it). Co-Authored-By: Claude Opus 5 (1M context) --- website/admin/grant_admin.py | 31 ++++++++++++++----- .../admin/website/grant/change_list.html | 10 ++++-- website/tests/test_grant_tracking.py | 31 +++++++++++++++++++ 3 files changed, 62 insertions(+), 10 deletions(-) diff --git a/website/admin/grant_admin.py b/website/admin/grant_admin.py index be9cfc1d..79bf3d45 100644 --- a/website/admin/grant_admin.py +++ b/website/admin/grant_admin.py @@ -27,8 +27,21 @@ class GrantAdmin(ArtifactAdmin): # 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 - list_display = ('title', 'date', 'get_first_author_last_name', 'sponsor', - 'uw_grant_id', 'funding_amount') + # + # Two audiences, two column sets (#1448). Editors (PhD students) get a lean + # lookup table with the worktag right beside the title — finding one is the + # whole reason they can see this page. Superusers get the same plus the + # funding amount, which pairs with the Total Funding rollup above the table. + # + # EDITOR_LIST_DISPLAY is an allowlist rather than a filtered-down copy of + # list_display on purpose: a column added for superusers later can't leak + # into the Editor view by accident. + # + # 'First Author (Last Name)' stays for superusers but is left off the Editor + # view, which is meant to stay a lean lookup table. + EDITOR_LIST_DISPLAY = ('title', 'uw_grant_id', 'sponsor', 'date') + list_display = EDITOR_LIST_DISPLAY + ('get_first_author_last_name', + 'funding_amount') # I want to make sponsor auto-complete but it's causing errors, so commenting out # https://github.com/makeabilitylab/makeabilitylabwebsite/issues/1093 @@ -44,7 +57,13 @@ class GrantAdmin(ArtifactAdmin): list_select_related = ('sponsor',) def get_queryset(self, request): - return super().get_queryset(request).prefetch_related('authors') + """Prefetch authors only when the first-author column is actually being + rendered — Editors don't get that column (#1448), so they shouldn't pay + for the join that feeds it.""" + queryset = super().get_queryset(request) + if 'get_first_author_last_name' in self.get_list_display(request): + queryset = queryset.prefetch_related('authors') + return queryset fieldsets = [ (None, {'fields': ['title', 'authors']}), @@ -81,11 +100,9 @@ def get_fieldsets(self, request, obj=None): def get_list_display(self, request): """Same boundary as get_fieldsets, applied to the changelist columns.""" - list_display = super().get_list_display(request) if request.user.is_superuser: - return list_display - return tuple(column for column in list_display - if column not in self.SUPERUSER_ONLY_FIELDS) + return super().get_list_display(request) + return self.EDITOR_LIST_DISPLAY def changelist_view(self, request, extra_context=None): """ diff --git a/website/templates/admin/website/grant/change_list.html b/website/templates/admin/website/grant/change_list.html index 837f90a6..36cf55c6 100644 --- a/website/templates/admin/website/grant/change_list.html +++ b/website/templates/admin/website/grant/change_list.html @@ -54,9 +54,13 @@ {% block extrastyle %} {{ block.super }}