Skip to content

Commit 8079879

Browse files
authored
Merge pull request #1449 from makeabilitylab/1448-uw-grant-tracking
Add UW grant tracking: worktag/award fields + admin links to the official UW trackers (#1448)
2 parents 8a43c0f + c7a9f1d commit 8079879

18 files changed

Lines changed: 742 additions & 55 deletions

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Custom admin organization lives in `website/admin/admin_site.py` (`MakeabilityLa
9292
- 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.
9393
- `Person``Project` via `ProjectRole` (with start/end dates). The `auto_close_project_roles` management command (run on every container start) closes expired roles.
9494
- `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.
95+
- **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.
9596
- Many M2M relations use `SortedManyToManyField` (vendored `sortedm2m` widget) so display order is editor-controlled, not alphabetical.
9697

9798
### URL routing quirks

docs/ADMIN_USERS_AND_GROUPS.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,12 @@ These are defined declaratively in
4141
`banner, person, position, project, keyword, talk, publication, poster, news,
4242
video, photo, projectumbrella, sponsor, projectrole`.
4343

44+
Plus one **read-only** exception: `grant` gets `view` and nothing else, so PhD
45+
students can look up a UW grant worktag / award number without pinging the PI.
46+
`GrantAdmin` additionally hides the funding amounts, the proposal PDFs/raw files,
47+
the total-funding rollup, and the official tracking links from anyone who isn't a
48+
superuser — so "funding data stays with the superuser" still holds.
49+
4450
**`Contributors`** — submit-and-review, never destroy:
4551
- `person`: `add`, `change`, `view` (edit bios)
4652
- `publication`, `talk`, `poster`, `projectrole`: `add` + `view` (create their
@@ -50,9 +56,13 @@ video, photo, projectumbrella, sponsor, projectrole`.
5056

5157
### Deliberately admin-only (neither group)
5258

53-
- **`Grant`** (Grants & Funding — funding data) and **`Award`** (curated external
54-
recognitions). Note: *paper* awards live on `Publication.award`, which Editors
55-
*can* edit via the publication; only the standalone `Award` model is withheld.
59+
- **`Grant`***editing* only; Editors can view it read-only (see above), but
60+
add/change/delete stay with the superuser, as do funding amounts and files.
61+
- **`Award`** (curated external recognitions). Note: *paper* awards live on
62+
`Publication.award`, which Editors *can* edit via the publication; only the
63+
standalone `Award` model is withheld.
64+
- **`GrantTrackingLink`** — bookmarks to the maintainer's UW CSE / UW Award
65+
Portal financial-reporting pages, shown atop the Grant changelist.
5666
- `User`, `Group`, `Permission`, `LogEntry`, sessions — account/audit administration.
5767

5868
## How it's enforced

docs/API.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ Filters: `?project=<short_name>`, `?sponsor=<sponsor short_name>`. Each grant
102102
includes its `sponsor`, `grant_id`, `grant_url`, and the `projects` it funds.
103103
Funding amounts are intentionally **not** exposed by the API.
104104

105+
> **Note:** `grant_id` is the *sponsor's* award ID (e.g. the NSF award number),
106+
> which is already public. UW's own tracking codes — `uw_grant_id` (the Workday
107+
> grant worktag), `uw_award_number`, and `uw_award_name` — are internal
108+
> administrative data and are intentionally **not** exposed, like `funding_amount`
109+
> and `email`.
110+
105111
### People — `GET /api/v1/people/`
106112

107113
Actual lab members (people with at least one Position); external co-authors are

makeabilitylab/settings.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,8 @@
8787
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
8888

8989
# Makeability Lab Global Variables, including Makeability Lab version
90-
ML_WEBSITE_VERSION = "2.33.0" # Keep this updated with each release and also change the short description below
91-
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)."
90+
ML_WEBSITE_VERSION = "2.34.0" # Keep this updated with each release and also change the short description below
91+
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)."
9292
DATE_MAKEABILITYLAB_FORMED = datetime.date(2012, 1, 1) # Date Makeability Lab was formed
9393
MAX_BANNERS = 7 # Maximum number of banners on a page
9494

website/admin/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
award_admin,
5656
banner_admin,
5757
grant_admin,
58+
grant_tracking_link_admin,
5859
keyword_admin,
5960
logentry_admin,
6061
news_admin,

website/admin/admin_site.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,10 @@ class MakeabilityLabAdminSite(admin.AdminSite):
6565
),
6666
(
6767
"Grants & Funding",
68-
["Grant", "Sponsor"],
69-
"Generally, Jon will handle these. Please contact him if you think you need to edit."
68+
["Grant", "Sponsor", "GrantTrackingLink"],
69+
"Generally, Jon will handle these. Please contact him if you think you need to edit. "
70+
"Grants are readable (but not editable) by Editors so you can look up a UW grant "
71+
"worktag or award number when purchasing, traveling, or filing an appointment."
7072
),
7173
(
7274
"Configuration",

website/admin/grant_admin.py

Lines changed: 113 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,47 @@
11
from django.contrib import admin
2-
from website.models import Grant
2+
from website.models import Grant, GrantTrackingLink
33
from django.db.models import Sum
44
from website.admin import ArtifactAdmin
55
from website.admin.admin_site import ml_admin_site
66

77
@admin.register(Grant, site=ml_admin_site)
88
class GrantAdmin(ArtifactAdmin):
99

10+
# Fields and columns only the superuser may see (#1448). Editors (PhD
11+
# students / staff) hold `view_grant` so they can look up a UW worktag, but
12+
# funding data and the proposal files were the reason Grant was superuser-only
13+
# in the first place (#1125), so those stay hidden. Enforced in
14+
# get_fieldsets / get_list_display / changelist_view below, and pinned by
15+
# website/tests/test_grant_tracking.py.
16+
SUPERUSER_ONLY_FIELDS = ('funding_amount', 'pdf_file', 'raw_file')
17+
1018
# search_fields are used for auto-complete, see:
1119
# https://docs.djangoproject.com/en/3.0/ref/contrib/admin/#django.contrib.admin.ModelAdmin.autocomplete_fields
1220
# Dropped 'date' (string-searching a DateField is unhelpful); added PI/Co-PI
1321
# (author) and sponsor name so grants are findable by people and funder.
22+
# The UW codes are searchable too — pasting a worktag from an email into the
23+
# search box is the main way this page gets used (#1448).
1424
search_fields = ['title', 'forum_name', 'authors__first_name',
15-
'authors__last_name', 'sponsor__name']
25+
'authors__last_name', 'sponsor__name',
26+
'uw_grant_id', 'uw_award_number', 'uw_award_name']
1627

1728
# The list display lets us control what is shown in the default talk table at Home > Website > Grants
1829
# See: https://docs.djangoproject.com/en/dev/ref/contrib/admin/#django.contrib.admin.ModelAdmin.list_display
19-
list_display = ('title', 'date', 'get_first_author_last_name', 'sponsor', 'funding_amount')
30+
#
31+
# Two audiences, two column sets (#1448). Editors (PhD students) get a lean
32+
# lookup table with the worktag right beside the title — finding one is the
33+
# whole reason they can see this page. Superusers get the same plus the
34+
# funding amount, which pairs with the Total Funding rollup above the table.
35+
#
36+
# EDITOR_LIST_DISPLAY is an allowlist rather than a filtered-down copy of
37+
# list_display on purpose: a column added for superusers later can't leak
38+
# into the Editor view by accident.
39+
#
40+
# 'First Author (Last Name)' stays for superusers but is left off the Editor
41+
# view, which is meant to stay a lean lookup table.
42+
EDITOR_LIST_DISPLAY = ('title', 'uw_grant_id', 'sponsor', 'date')
43+
list_display = EDITOR_LIST_DISPLAY + ('get_first_author_last_name',
44+
'funding_amount')
2045

2146
# I want to make sponsor auto-complete but it's causing errors, so commenting out
2247
# https://github.com/makeabilitylab/makeabilitylabwebsite/issues/1093
@@ -32,63 +57,126 @@ class GrantAdmin(ArtifactAdmin):
3257
list_select_related = ('sponsor',)
3358

3459
def get_queryset(self, request):
35-
return super().get_queryset(request).prefetch_related('authors')
60+
"""Prefetch authors only when the first-author column is actually being
61+
rendered — Editors don't get that column (#1448), so they shouldn't pay
62+
for the join that feeds it."""
63+
queryset = super().get_queryset(request)
64+
if 'get_first_author_last_name' in self.get_list_display(request):
65+
queryset = queryset.prefetch_related('authors')
66+
return queryset
3667

3768
fieldsets = [
3869
(None, {'fields': ['title', 'authors']}),
3970
('Grant Info', {'fields': ['date', 'end_date', 'sponsor', 'funding_amount', 'forum_url', 'grant_id']}),
71+
('UW Internal Tracking', {'fields': ['uw_grant_id', 'uw_award_number', 'uw_award_name'],
72+
'description': 'UW/Workday administrative codes for this award. '
73+
'These are <b>internal</b>: they are never shown on the '
74+
'public site and are deliberately excluded from the REST API.'}),
4075
('Grant Files', {'fields': ['pdf_file', 'raw_file']}),
4176
('Project Info', {'fields': ['projects', 'project_umbrellas']}),
4277
('Keyword Info', {'fields': ['keywords']}),
4378
]
4479

80+
def get_fieldsets(self, request, obj=None):
81+
"""Drop the funding/file fields for non-superusers.
82+
83+
Editors get `view_grant` only, so Django already renders this form
84+
read-only; this narrows *what* they can read. Any section left empty
85+
(i.e. 'Grant Files') disappears entirely rather than rendering a header
86+
with nothing under it.
87+
"""
88+
fieldsets = super().get_fieldsets(request, obj)
89+
if request.user.is_superuser:
90+
return fieldsets
91+
92+
visible = []
93+
for name, options in fieldsets:
94+
fields = [f for f in options['fields']
95+
if f not in self.SUPERUSER_ONLY_FIELDS]
96+
if fields:
97+
# New dict per request — never mutate the class-level fieldsets.
98+
visible.append((name, {**options, 'fields': fields}))
99+
return visible
100+
101+
def get_list_display(self, request):
102+
"""Same boundary as get_fieldsets, applied to the changelist columns."""
103+
if request.user.is_superuser:
104+
return super().get_list_display(request)
105+
return self.EDITOR_LIST_DISPLAY
106+
45107
def changelist_view(self, request, extra_context=None):
46108
"""
47-
Override the changelist view to include total funding amount.
48-
49-
This calculates the sum of all funding_amount values and passes it
50-
to the template context for display at the top of the grants list.
109+
Override the changelist view to include total funding amount and the
110+
official UW tracking links.
111+
112+
Both are superuser-only: the funding rollup is the aggregate of the data
113+
we hide per-row from Editors, and the tracking links point at the
114+
maintainer's personal financial-reporting systems (#1448).
51115
"""
52116
# Get the base queryset (respects any active filters)
53117
response = super().changelist_view(request, extra_context)
54-
118+
55119
# Only proceed if we have a context (not a redirect response)
56-
if hasattr(response, 'context_data'):
120+
if hasattr(response, 'context_data') and request.user.is_superuser:
57121
# Get the filtered queryset from the changelist
58122
cl = response.context_data.get('cl')
59123
if cl:
60124
queryset = cl.queryset
61125
else:
62126
queryset = self.get_queryset(request)
63-
127+
64128
# Calculate total funding from the (possibly filtered) queryset
65129
total = queryset.aggregate(
66130
total_funding=Sum('funding_amount')
67131
)['total_funding'] or 0
68-
132+
69133
response.context_data['total_funding'] = total
70-
134+
response.context_data['grant_tracking_links'] = GrantTrackingLink.objects.all()
135+
71136
return response
72137

73138
def get_form(self, request, obj=None, **kwargs):
74139
form = super().get_form(request, obj, **kwargs)
75140

76-
form.base_fields['authors'].label = 'PIs and Co-PIs'
77-
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."
141+
def tweak(field_name, **attrs):
142+
"""Apply label/help_text overrides to a field if this form has it.
78143
79-
form.base_fields['date'].label = 'Start date'
80-
form.base_fields['date'].help_text = 'Start date for the grant'
144+
Non-superusers get a reduced fieldset (see get_fieldsets), so the
145+
funding and file fields are simply absent from their form — look
146+
them up defensively rather than KeyError-ing on a view-only render.
147+
"""
148+
field = form.base_fields.get(field_name)
149+
if field is None:
150+
return
151+
for attr, value in attrs.items():
152+
setattr(field, attr, value)
153+
154+
tweak('authors',
155+
label='PIs and Co-PIs',
156+
help_text="The first author is assumed to be the PI. Co-PIs should be listed in the order they appear on the grant.")
157+
158+
tweak('date', label='Start date', help_text='Start date for the grant')
81159

82-
form.base_fields['forum_url'].label = 'Grant url'
83160
grant_url = "https://www.nsf.gov/awardsearch/showAward?AWD_ID=1302338"
84-
form.base_fields['forum_url'].help_text = f'The grant url (e.g., <a href="{grant_url}">{grant_url}</a>)'
161+
tweak('forum_url',
162+
label='Grant url',
163+
help_text=f'The grant url (e.g., <a href="{grant_url}">{grant_url}</a>)')
164+
165+
# NB: 'grant_id' is disambiguated from 'UW Grant ID (worktag)' by a
166+
# verbose_name on the model, not here — a label set on the form is
167+
# ignored when Django renders the read-only view Editors get.
85168

86-
form.base_fields['pdf_file'].label = 'Grant PDF'
87-
form.base_fields['pdf_file'].help_text = 'The rendered PDF of the grant. Internal only. This is not currently shown on the website.'
88-
form.base_fields['raw_file'].help_text = 'The raw file (e.g., Word Docx, Overleaf Zip, etc.) for <b>archival</b> purposes. This is not shown on the website.'
169+
tweak('pdf_file',
170+
label='Grant PDF',
171+
help_text='The rendered PDF of the grant. Internal only. This is not currently shown on the website.')
172+
tweak('raw_file',
173+
help_text='The raw file (e.g., Word Docx, Overleaf Zip, etc.) for <b>archival</b> purposes. This is not shown on the website.')
89174

90-
form.base_fields['projects'].help_text = 'Associate this grant with all the projects that it supports.'
175+
tweak('projects',
176+
help_text='Associate this grant with all the projects that it supports.')
91177

92-
form.base_fields['funding_amount'].widget.attrs['style'] = f'min-width: 300px;'
178+
funding_amount = form.base_fields.get('funding_amount')
179+
if funding_amount is not None:
180+
funding_amount.widget.attrs['style'] = 'min-width: 300px;'
93181

94-
return form
182+
return form
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from django.contrib import admin
2+
from django.utils.html import format_html
3+
4+
from website.models import GrantTrackingLink
5+
from website.admin.admin_site import ml_admin_site
6+
7+
8+
@admin.register(GrantTrackingLink, site=ml_admin_site)
9+
class GrantTrackingLinkAdmin(admin.ModelAdmin):
10+
"""
11+
Admin for the official grant-tracking bookmarks shown atop the Grant
12+
changelist (#1448).
13+
14+
Superuser-only, by the same mechanism as Grant and Award: this model is
15+
absent from EDITORS_MODELS / CONTRIBUTORS_SPEC in the setup_admin_groups
16+
management command, so neither group is ever granted its permissions.
17+
"""
18+
19+
list_display = ('label', 'link', 'notes', 'display_order')
20+
list_editable = ('display_order',)
21+
ordering = ('display_order', 'label')
22+
23+
fieldsets = [
24+
(None, {
25+
'fields': ['label', 'url', 'notes', 'display_order'],
26+
'description': 'Links to the official UW systems that track our grants '
27+
'(UW CSE financial reporting, the UW Award Portal, ...). '
28+
'They are shown at the top of the Grants page, to superusers only. '
29+
'These are stored here rather than in the code because the URLs can '
30+
'contain personal sharing tokens and this repository is public.',
31+
}),
32+
]
33+
34+
@admin.display(description='Link')
35+
def link(self, obj):
36+
"""Clickable, truncated URL — SharePoint URLs are long enough to blow out
37+
the changelist column otherwise."""
38+
display = obj.url if len(obj.url) <= 80 else f"{obj.url[:80]}…"
39+
return format_html('<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
40+
obj.url, display)

website/api/serializers.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,17 @@ class SponsorSummarySerializer(serializers.Serializer):
240240

241241
class GrantSerializer(serializers.ModelSerializer):
242242
"""A funding grant. ``start_date`` and ``grant_url`` are model properties
243-
aliasing the shared Artifact ``date`` / ``forum_url`` fields."""
243+
aliasing the shared Artifact ``date`` / ``forum_url`` fields.
244+
245+
``fields`` below is an explicit allowlist, and deliberately so: ``Grant`` also
246+
carries UW's internal Workday codes (``uw_grant_id`` — the grant worktag —
247+
plus ``uw_award_number`` / ``uw_award_name``) and ``funding_amount``. Those are
248+
internal administrative data and must never be published here, the same way
249+
``Person.email`` is withheld from the people endpoints. Do not switch this to
250+
``exclude`` or ``__all__``; ``test_api.py`` pins the omission (#1448).
251+
252+
Note that ``grant_id`` IS public — it is the *sponsor's* award ID (the NSF
253+
number), not UW's."""
244254

245255
sponsor = SponsorSummarySerializer(read_only=True)
246256
grant_url = serializers.URLField(read_only=True)

0 commit comments

Comments
 (0)