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
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions docs/ADMIN_USERS_AND_GROUPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ Filters: `?project=<short_name>`, `?sponsor=<sponsor short_name>`. 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
Expand Down
4 changes: 2 additions & 2 deletions makeabilitylab/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions website/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
award_admin,
banner_admin,
grant_admin,
grant_tracking_link_admin,
keyword_admin,
logentry_admin,
news_admin,
Expand Down
6 changes: 4 additions & 2 deletions website/admin/admin_site.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
138 changes: 113 additions & 25 deletions website/admin/grant_admin.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,47 @@
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

@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')
#
# 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
Expand All @@ -32,63 +57,126 @@ 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']}),
('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 <b>internal</b>: 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."""
if request.user.is_superuser:
return super().get_list_display(request)
return self.EDITOR_LIST_DISPLAY

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., <a href="{grant_url}">{grant_url}</a>)'
tweak('forum_url',
label='Grant url',
help_text=f'The grant url (e.g., <a href="{grant_url}">{grant_url}</a>)')

# 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 <b>archival</b> 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 <b>archival</b> 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
return form
40 changes: 40 additions & 0 deletions website/admin/grant_tracking_link_admin.py
Original file line number Diff line number Diff line change
@@ -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('<a href="{}" target="_blank" rel="noopener noreferrer">{}</a>',
obj.url, display)
12 changes: 11 additions & 1 deletion website/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading