diff --git a/.pa11yci.json b/.pa11yci.json index 2a18a81a..3339eb10 100644 --- a/.pa11yci.json +++ b/.pa11yci.json @@ -39,6 +39,7 @@ "http://website:8000/news/", "http://website:8000/news/1/", "http://website:8000/news/2/", - "http://website:8000/news/3/" + "http://website:8000/news/3/", + "http://website:8000/awards/" ] } \ No newline at end of file diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 59b0b948..065ad2f1 100755 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -125,6 +125,11 @@ echo "4.8 Running 'python manage.py recompute_url_names' to de-collide historica echo "******************************************" python manage.py recompute_url_names +echo "****************** STEP 4.9/5: docker-entrypoint.sh ************************" +echo "4.9 Running 'python manage.py import_awards' to backfill missing Awards (idempotent)" +echo "******************************************" +python manage.py import_awards + # echo "****************** STEP 4.3/5: docker-entrypoint.sh ************************" # echo "4.3 Running 'python manage.py rename_person_images' to rename person images" # echo "******************************************" diff --git a/docs/plans/awards-content-audit.md b/docs/plans/awards-content-audit.md new file mode 100644 index 00000000..344bcf94 --- /dev/null +++ b/docs/plans/awards-content-audit.md @@ -0,0 +1,159 @@ +# Awards page — content audit & redesign plan (#issue TBD) + +Source data: prod snapshot `~/Downloads/makeability-prod-2026-06-14.sql.gz`, loaded into a +scratch DB (`award_scan`) and queried read-only. As of the snapshot there are **21** +`Award` rows and **298** news items; **54** news posts hit award keywords. + +Two distinct buckets matter here: + +- **`Award` model** → people / project / faculty *distinctions* (this page's top sections). +- **`Publication.award`** → **paper awards** (Best Paper, Honorable Mention). These render in + the page's "Best Paper Awards" / "Other Paper Awards" sections and must be set on the + *publication*, **not** entered as `Award` rows. Many news items below are paper awards — they + need a publication audit, not an Award entry. + +Legend: `[ ]` = candidate to add · `STU` Student Award · `PHD` PhD Fellowship · `FAC` Faculty +Honor · `PROJ` Project Award · `PAPER` → goes on `Publication.award` · ⚠ needs a decision. + +--- + +## ✅ Resolution (2026-06-19) — implemented as `import_awards` + +Decisions from Jon's review are now encoded in the idempotent +`website/management/commands/import_awards.py` command (wired into +`docker-entrypoint.sh` step 4.9; `--dry-run` supported; covered by +`website/tests/test_import_awards.py`). That command's `ENTRIES` list is the +authoritative spec — this doc is the rationale. **Nothing ships until the +`awards-update` branch merges**, so trim entries there first. + +What changed from the raw audit after checking the Grants table + publications: + +- **Google awards use canonical titles** (from Grants): 2013 "Combining + Crowdsourcing and Computer Vision for Street-level Accessibility", 2017 + "Wearable Sound Awareness Support for the Deaf and Hard of Hearing" (GlassEar), + 2024 "Exploring AI-Enhanced Mixed-Ability Social Interactions" (Society-Centered + AI). The **2020** "Transforming How Blind and Low Vision Developers…" award is + **not in the Grants table** — consider adding it there too. +- **Section D is closed.** Every paper award is already on its publication — + including SoundWatch (Best Artifact) and **UbiFit's 10-Year Impact Award** + ("Flowers or a robot army?", 2008) — so those are **not** added as Award rows. + The **only** missing paper award is **"Playing on Hard Mode"** (pub #685, award + field empty) → spin off a **separate issue** to set `Publication.award`. +- **Bonus find:** a **2013 "3M Non-Tenured Faculty Award"** (in Grants, not on the + CV list) is included as a Faculty Honor — confirm or drop. +- **Aditya / AJAS** (news 2024-10-02) is the **same** recognition as existing + award #20 (WA State delegate) → **skipped**. +- **#18 Facilitators' Choice** date fix (2020→2019 + PrototypAR) is applied + automatically by the command. +- Items flagged `# REVIEW` in `ENTRIES` are lower-confidence (a guessed date, + venue, or recipient): the 2013-vs-2012 Google date, the 3M award, "Inventors in + our Midst", the CV-only dissertation/BECC items, and the Madrona/AltGeoViz + venue details. Confirm or trim before merge. + +--- + +## A. Faculty Honors — missing from the 21 + +- [ ] **2017 · NSF CAREER Award** (FAC) — Jon. News: `/news/nsf-career-award/` (2017-02-14). *On your CV; not in DB.* +- [ ] **2017 · Google Faculty Research Award — GlassEar** (FAC) — Jon, Leah Findlater. News: `/news/google-faculty-award-on-glassear/`. *CV ✓.* +- [ ] **2018 · 10-Year Impact Award — UbiFit** (FAC, test-of-time) — Jon. News: `/news/10-year-impact-award-for-ubifit/`. *Not on the CV list you sent — worth adding.* +- [ ] **2024 · Society-Centered AI Google Research Award** (FAC) — Jon, Jacob Wobbrock, Dhruv Jain, Arnavi Chheda-Kothary. News: `/news/society-centered-ai-google-research-award/`. +- [ ] **2012 · Google Faculty Research Award** — street-level accessibility (FAC) — Jon. *CV only; no news post found.* +- [ ] **2013 · "Inventors in our Midst", 1st DC-area Maker Faire** (FAC ⚠ or PROJ) — Jon. *CV only.* + +In addition to the list above, I also received a Google Faculty Research Award for "Transforming How Blind and Low Vision Developers Design and Implement User Interfaces" in February 2020. + +Here's their email: "We would like to thank you for submitting your proposal "Transforming How Blind and Low Vision Developers Design and Implement User Interfaces," to our Google Faculty Research Awards program. We appreciate your patience, as we conduct a very thorough review of all the submissions that we receive, involving several teams of Google engineers and researchers." + +If possible, for all Google-related awards, I'd like to use actual titles. We should check that these are also in the grants database on that production dump. + +## B. Student Awards & Fellowships — missing from the 21 + +- [ ] **2026 · NSF GRFP** (PHD) — Michael Duan **and** Ritesh Kanchi (alums). News: `/news/two-alums-receive-nsf-grfp/`. ⚠ one entry honoring both, or two? (recommend one). +- [ ] **2024 · CRA Outstanding Undergraduate Researcher — Honorable Mention** (STU) — Ritesh Kanchi. News: `/news/ritesh-earns-honorable-mention-for-cra-outstanding-undergraduate-researcher/`. +- [ ] **2024 · ACM Student Research Competition (Tapia)** (STU) — Daniel Campos Zamora. News: `/news/daniel-wins-acm-student-research-competition-at-the-tapia-conference/`. +- [ ] **2024 · American Junior Academy of Science — selected** (STU) — Aditya Sengupta. News: `/news/...selected-to-attend-american-junior-academy-of-science/`. ⚠ Possibly the same recognition as existing award #20 (Aditya, WA State delegate, 2024) — verify before adding. +- [ ] **2022 · CRA Outstanding Undergraduate Researcher** (STU) — Michael Duan. News: `/news/congrats-michael-duan-for-cra-undergrad-award/`. +- [ ] **2021 · Google-CMD-IT LEAP Alliance Fellowship** (PHD) — Dhruv Jain. News: `/news/dhruv-jain-selected-for-google-cmd-it-leap-alliance-fellowship/`. +- [ ] **2021 · Bob Bandes Memorial Teaching Award — Honorable Mention** (STU, teaching) — Liang He. News: `/news/liang-he-receives-bob-bandes-memorial-honorable-mention-teaching-award/`. +- [ ] **2019 · National SWE Scholarship** (STU) — Aileen Zeng. News: `/news/aileen-awarded-national-swe-scholarship/`. +- [ ] **2019 · Mary Gates Research Scholarship** (STU) — Aileen Zeng. News: `/news/aileen-zeng-awarded-mary-gates-research-scholarship/`. +- [ ] **2019 · Google Lime Scholarship** (STU) — Venkatesh Potluri. News: `/news/venkatesh-receives-google-lime-scholarship/`. +- [ ] **2017 · All-S.T.A.R. Fellow** (PHD/STU) — Matt Mauriello. News: `/news/matt-mauriello-selected-as-all-star-fellow/`. +- [ ] **2016 · ACM-W Scholarship (CHI 2016)** (STU) — Manaswi Saha. News: `/news/acm-w-scholarship-to-attend-chi-2016/`. +- [ ] **2012 · Graduate School Distinguished Dissertation Award, UW** (STU) — Jon. *CV only.* +- [ ] **2012 · CGS/ProQuest Distinguished Dissertation Award — Honorable Mention** (STU) — Jon. *CV only.* +- [ ] **2009 · Precourt Center Fellow, BECC** (PHD/STU) — Jon. *CV only.* + +Low-priority / ⚠ judgment calls (probably skip or batch): +- [ ] 2016 · Matt Mauriello "Future Faculty Program" — *selected*, not an award. (skip?) +- [ ] 2023 · Ritesh — Google Product Inclusion & Equity Summit — *attended*, not an award. (skip?) +- [ ] 2018 · Dhruv — Tapia travel award / AccessComputing travel award — minor travel grants. (skip?) + +Agree with this list and things to skip. If possible, when we create these entries, can we make sure to link to canonical sources. + +## C. Project Awards — missing from the 21 + +- [ ] **2024 · Smart City Hub Switzerland Award (ZüriACT)** (PROJ) — Project Sidewalk. News: `/news/zuriact-with-project-sidewalk-win-smart-city-hub-switzerland-award-2024/`. +- [ ] **2024 · People's Choice Award — AltGeoViz** (PROJ ⚠) — Chu Li / AltGeoViz. News: `/news/altgeoviz-receives-people-choice-award/`. ⚠ could be a demo/poster award. +- [ ] **2020 · Best Artifact Award, ASSETS — SoundWatch** (PROJ ⚠) — Dhruv Jain, Leah Findlater. News: `/news/soundwatch-wins-best-artifact-award-at-assets/`. ⚠ artifact award — PROJ vs PAPER. +- [ ] **2019 · People's Choice Award — HomeSound** (PROJ) — Dhruv Jain. News: `/news/homesound-wins-peoples-choice-award/`. +- [ ] **2018 · People's Choice Award, Allen School Research Day — AR Captioning** (PROJ) — Dhruv Jain. News: `/news/ar-captioning-wins-the-peoples-choice-award.../`. *CV "2018 People's Choice [C.44]" ✓.* +- [ ] **2016 · Facilitators' Choice Award, NSF Video Showcase — BodyVis** (PROJ) — "13 of 156 (8.3%)". *CV ✓; missing from DB.* +- [ ] **2019 · Madrona Innovation Prize — HomeSound [C.58]** (PROJ ⚠ or PAPER) — *CV; verify whether to attach to the HomeSound paper instead.* + +Good list. + +## D. Paper awards found in news → set on `Publication.award` (NOT Award rows) + +These should be entered on the **publication's** Award field so they appear in the page's +Best/Other Paper sections. Needs a separate publication audit. + +- [ ] 2026 · Best Paper, GAZE'26 workshop — "Causal Egocentric Gaze Estimation" +- [ ] 2026 · Best Paper, CHI'26 — GeoVisA11y +- [ ] 2024 · Best Paper, ASSETS'24 — ArtInsight ("Engaging with Children's Artwork…") +- [ ] 2024 · Best Paper (Belonging & Inclusion), UIST'24 — CookAR +- [ ] 2024 · Best Paper, IDEATExR'24 — ARSports / ARTennis +- [ ] 2024 · Best Paper **Nomination**, ASSETS'24 — ArtInsight (nomination only) +- [ ] 2021 · Honorable Mention + D&I Award, CSCW'21 — Small Group Captioning +- [ ] 2019 · Best Student Paper, ASSETS'19 — Deep Learning for Sidewalk Assessment +- [ ] 2019 · two Best Paper noms, ASSETS'19 (nominations only) +- [ ] 2019 · two Best Paper Awards, CHI'19 — Project Sidewalk / Anchored Audio Sampling +- [ ] 2017 · Best Paper, CHI'17 — MakerWear +- [ ] 2024 · **Best Academic Research, The Game Accessibility Conference** — "Playing on Hard Mode" [C.69]. *CV ✓.* + +I'm pretty sure these are all already marked in publications, except for "Playing on Hard Mode". We could do a double check and create a separate Issue for this. + +## E. Excluded (matched keywords but not awards) + +Grants: Mapillary Camera Grant (2025), $1.2m NSF Grant (2018), GPSS Travel Grant (2020). +Other: "Project Sidewalk used in award-winning APA paper" (someone else's award); Distinguished +Lecture @ UMN (invited talk); CS Distinguished Lecture: Bjoern Hartmann (external speaker); +Liang's CHI'19 t-shirt design contest. + +I think we can ignore these. + +## F. Data fixes to existing rows + +- [ ] **Award #18 — Facilitators' Choice (NSF Video Showcase)**: dated **2020-05-20**, description + "21 of 242 (8.7%)". That stat + news #113 indicate this is the **2019 PrototypAR** award. + Fix the date to 2019 and attach the **PrototypAR** project (recipient Seokbin Kang). +- [ ] Confirm William Chan #9 (Jon, 2012) vs #10 (Dhruv, 2023) — both correct, leave as-is. + +Good call. And yes, both me and Dhruv got the same award. + +## G. CV items already represented (no action) + +SIGCHI Societal Impact 2026, PacTrans 2022, COE Outstanding Faculty 2021, Sloan 2017, +Madrona Prize 2009, Best Clean-Tech / UW Business Plan 2009, 1st Place UW Env. Innovation +Challenge 2009, MSR Graduate Fellowship 2008, COE Student Innovator 2010, William Chan 2012, +Project Sidewalk / Scistarter 2023. + +--- + +### Open question for Jon +Once you've triaged A–D, do you want these entered **by hand in the admin**, or shipped as an +**idempotent `import_awards` management command** (wired into `docker-entrypoint.sh`, the +established pattern) so they land on test/prod via deploy? ~25 new rows makes the command worth it. + +I'd like to use the command approach. diff --git a/makeabilitylab/settings.py b/makeabilitylab/settings.py index abb25be2..4b71dc3b 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.13.0" # Keep this updated with each release and also change the short description below -ML_WEBSITE_VERSION_DESCRIPTION = "Feature: replace django-ckeditor (CKEditor 4, EOL with unpatched XSS) with django-prose-editor for the News editor (#1269), unblocking the Django 6.1 LTS upgrade. Content stays raw HTML and migrates near-losslessly; sanitized on save via an nh3 allowlist. In-body image upload moves to a staff-only picker view (reuses media/uploads/ + validate_image_upload); adds an 'Edit HTML' source view. News images are now responsive by default — a one-shot normalize_news_image_styles command strips legacy inline width/height. A small in-repo ckeditor_uploader shim keeps gitignored historical migrations importable (cleanup tracked in #1317). Field swap is TEXT->TEXT (no schema change)." +ML_WEBSITE_VERSION = "2.14.0" # Keep this updated with each release and also change the short description below +ML_WEBSITE_VERSION_DESCRIPTION = "Feature: redesigned Awards page. Each award now renders as a card with a category-specific visual anchor — recipient photo (student awards), project thumbnail (project awards), or a gold medal icon (faculty honors), with an optional uploaded emblem (new Award.badge field) overriding any of these — led by a prominent golden-orange year badge. A new idempotent import_awards management command (wired into docker-entrypoint.sh) backfills ~27 people/project/faculty awards mined from the news archive + CV that were missing from the Awards page, and corrects the mis-dated Facilitators' Choice (PrototypAR) entry. Paper awards stay on Publication.award; the one gap ('Playing on Hard Mode') is tracked in #1354. /awards/ added to the Pa11y scan set." 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/award_admin.py b/website/admin/award_admin.py index 1de1fc4d..011a88f4 100644 --- a/website/admin/award_admin.py +++ b/website/admin/award_admin.py @@ -79,6 +79,13 @@ def get_fieldsets(self, request, obj=None): ('Links & Details', { 'fields': ['url', 'description'], }), + ('Display', { + 'fields': ['badge', 'badge_alt_text'], + 'description': 'Optional. On the Awards page, faculty honors show a medal icon, ' + 'student awards show the recipient’s photo, and project awards ' + 'show the project thumbnail. Upload a badge/logo here to override ' + 'that with a custom emblem (e.g., the awarding org’s logo).', + }), ] def formfield_for_manytomany(self, db_field, request, **kwargs): diff --git a/website/management/commands/import_awards.py b/website/management/commands/import_awards.py new file mode 100644 index 00000000..46e7d54d --- /dev/null +++ b/website/management/commands/import_awards.py @@ -0,0 +1,259 @@ +""" +Idempotent importer for the Awards page (#awards-update). + +Backfills the people / project / faculty *distinctions* that were missing from +the Award table (sourced from the news archive + Jon's CV; see +docs/plans/awards-content-audit.md). Paper awards are intentionally NOT here — +those live on ``Publication.award`` and are already set (the lone exception, +"Playing on Hard Mode", is tracked as a separate issue). + +Design: + * Idempotent. Each award is keyed by (title, date); an existing match is left + untouched (so manual admin edits win) and only missing ones are created. + Safe to run on every container start, like the other one-shot commands in + docker-entrypoint.sh. + * Recipients/projects are resolved by name against the live DB. A name that + doesn't resolve is logged as a warning and skipped; the award is still + created with whatever honorees did resolve (so a renamed person never blocks + the rest of the import). + * ``--dry-run`` reports what would happen without writing. + +Editors: to drop an award you don't want, delete its dict from ENTRIES before +this ships (nothing deploys until the branch merges). +""" + +from datetime import date + +from django.core.management.base import BaseCommand +from django.db import transaction + +from website.models import Award, Person, Project +from website.models.award import AwardType + +NEWS = "https://makeabilitylab.cs.washington.edu/news/" + +# Each entry: title, organization, date, award_type, url, description, +# recipients (list of "First Last" — matched to Person), projects (list of +# Project.name). Recipients use the EXACT stored name (e.g. Daniel's surname is +# "Zamora", first name "Daniel Campos"). +ENTRIES = [ + # ---- Faculty Honors ---------------------------------------------------- + dict(title="NSF CAREER Award", organization="National Science Foundation", + date=date(2017, 2, 14), award_type=AwardType.FACULTY_HONOR, + url=NEWS + "nsf-career-award/", + description="A Tangible-Graphical Approach to Engage Young Children in Wearable Design.", + recipients=["Jon Froehlich"], projects=[]), + dict(title="Google Faculty Research Award", organization="Google", + date=date(2013, 2, 1), award_type=AwardType.FACULTY_HONOR, url=None, + description="For “Combining Crowdsourcing and Computer Vision for Street-level Accessibility.”", + recipients=["Jon Froehlich"], projects=["Project Sidewalk"]), # Submitted Oct 2012, awarded Feb 2013. + dict(title="Google Faculty Research Award", organization="Google", + date=date(2017, 2, 15), award_type=AwardType.FACULTY_HONOR, + url=NEWS + "google-faculty-award-on-glassear/", + description="For “Wearable Sound Awareness Support for the Deaf and Hard of Hearing” (Project GlassEar).", + recipients=["Jon Froehlich", "Leah Findlater"], projects=["GlassEar"]), + dict(title="Google Faculty Research Award", organization="Google", + date=date(2020, 2, 1), award_type=AwardType.FACULTY_HONOR, url=None, + description="For “Transforming How Blind and Low Vision Developers Design and Implement User Interfaces.”", + recipients=["Jon Froehlich"], projects=[]), # Now also in the Grants table (added on prod). + dict(title="Google Faculty Research Award", organization="Google", + date=date(2024, 10, 1), award_type=AwardType.FACULTY_HONOR, + url=NEWS + "society-centered-ai-google-research-award/", + description="Society-Centered AI Research Award for “Exploring AI-Enhanced Mixed-Ability Social Interactions.”", + recipients=["Jon Froehlich", "Jacob Wobbrock", "Dhruv Jain", "Arnavi Chheda-Kothary"], projects=[]), + dict(title="3M Non-Tenured Faculty Award", organization="3M", + date=date(2013, 1, 1), award_type=AwardType.FACULTY_HONOR, url=None, + description="Using Machine Learning and Intelligent Sensing to Promote Activity Awareness and Modification.", + recipients=["Jon Froehlich"], projects=[]), # In the Grants DB (not on the CV list); confirmed for the awards page. + dict(title="Inventors in our Midst", organization="1st DC-area Maker Faire", + date=date(2013, 9, 29), award_type=AwardType.FACULTY_HONOR, url=None, + description="One of four recognized at the inaugural DC-area Maker Faire.", + recipients=["Jon Froehlich"], projects=[]), # CV only; confirmed. + + # ---- PhD Fellowships --------------------------------------------------- + dict(title="NSF Graduate Research Fellowship", organization="National Science Foundation", + date=date(2026, 4, 13), award_type=AwardType.PHD_FELLOWSHIP, + url=NEWS + "two-alums-receive-nsf-grfp/", description=None, + recipients=["Michael Duan", "Ritesh Kanchi"], projects=[]), + dict(title="Google-CMD-IT LEAP Alliance Fellowship", organization="Google / CMD-IT", + date=date(2021, 8, 5), award_type=AwardType.PHD_FELLOWSHIP, + url=NEWS + "dhruv-jain-selected-for-google-cmd-it-leap-alliance-fellowship/", description=None, + recipients=["Dhruv Jain"], projects=[]), + dict(title="All-S.T.A.R. Fellow", organization="NSF All-S.T.A.R. Program", + date=date(2017, 4, 25), award_type=AwardType.PHD_FELLOWSHIP, + url=NEWS + "matt-mauriello-selected-as-all-star-fellow/", description=None, + recipients=["Matthew Mauriello"], projects=[]), + dict(title="Precourt Center Fellow", organization="Behavior, Energy and Climate Change Conference (BECC)", + date=date(2009, 1, 1), award_type=AwardType.PHD_FELLOWSHIP, url=None, description=None, + recipients=["Jon Froehlich"], projects=[]), # CV only; confirmed. + + # ---- Student Awards ---------------------------------------------------- + dict(title="CRA Outstanding Undergraduate Researcher Award — Honorable Mention", + organization="Computing Research Association", date=date(2024, 12, 18), + award_type=AwardType.STUDENT_AWARD, + url=NEWS + "ritesh-earns-honorable-mention-for-cra-outstanding-undergraduate-researcher/", + description=None, recipients=["Ritesh Kanchi"], projects=[]), + dict(title="ACM Student Research Competition — Winner", organization="ACM Richard Tapia Conference", + date=date(2024, 9, 20), award_type=AwardType.STUDENT_AWARD, + url=NEWS + "daniel-wins-acm-student-research-competition-at-the-tapia-conference/", + description=None, recipients=["Daniel Campos Zamora"], projects=[]), + dict(title="CRA Outstanding Undergraduate Researcher Award", organization="Computing Research Association", + date=date(2022, 12, 16), award_type=AwardType.STUDENT_AWARD, + url=NEWS + "congrats-michael-duan-for-cra-undergrad-award/", + description=None, recipients=["Michael Duan"], projects=[]), + dict(title="Bob Bandes Memorial Teaching Award — Honorable Mention", + organization="UW Allen School of Computer Science & Engineering", date=date(2021, 6, 14), + award_type=AwardType.STUDENT_AWARD, + url=NEWS + "liang-he-receives-bob-bandes-memorial-honorable-mention-teaching-award/", + description=None, recipients=["Liang He"], projects=[]), + dict(title="National SWE Scholarship", organization="Society of Women Engineers", + date=date(2019, 6, 11), award_type=AwardType.STUDENT_AWARD, + url=NEWS + "aileen-awarded-national-swe-scholarship/", + description=None, recipients=["Aileen Zeng"], projects=[]), + dict(title="Mary Gates Research Scholarship", organization="University of Washington", + date=date(2019, 3, 13), award_type=AwardType.STUDENT_AWARD, + url=NEWS + "aileen-zeng-awarded-mary-gates-research-scholarship/", + description=None, recipients=["Aileen Zeng"], projects=[]), + dict(title="Google Lime Scholarship", organization="Google", + date=date(2019, 3, 1), award_type=AwardType.STUDENT_AWARD, + url=NEWS + "venkatesh-receives-google-lime-scholarship/", + description=None, recipients=["Venkatesh Potluri"], projects=[]), + dict(title="ACM-W Scholarship", organization="ACM-W", + date=date(2016, 3, 1), award_type=AwardType.STUDENT_AWARD, + url=NEWS + "acm-w-scholarship-to-attend-chi-2016/", + description=None, recipients=["Manaswi Saha"], projects=[]), + dict(title="Graduate School Distinguished Dissertation Award", organization="University of Washington", + date=date(2012, 6, 1), award_type=AwardType.STUDENT_AWARD, url=None, + description=None, recipients=["Jon Froehlich"], projects=[]), # CV only; confirmed. + dict(title="CGS/ProQuest Distinguished Dissertation Award — Honorable Mention", + organization="Council of Graduate Schools / ProQuest", date=date(2012, 1, 1), + award_type=AwardType.STUDENT_AWARD, url=None, + description="In Mathematics, Physical Sciences, and Engineering.", + recipients=["Jon Froehlich"], projects=[]), # CV only; confirmed. + + # ---- Project Awards ---------------------------------------------------- + dict(title="Smart City Hub Switzerland Award", organization="Smart City Hub Switzerland", + date=date(2024, 12, 15), award_type=AwardType.PROJECT_AWARD, + url=NEWS + "zuriact-with-project-sidewalk-win-smart-city-hub-switzerland-award-2024/", + description="Awarded to ZüriACT, which builds on Project Sidewalk.", + recipients=[], projects=["Project Sidewalk"]), + dict(title="People’s Choice Award", organization=None, + date=date(2024, 10, 29), award_type=AwardType.PROJECT_AWARD, + url=NEWS + "altgeoviz-receives-people-choice-award/", + description=None, recipients=["Chu Li"], projects=["AltGeoViz"]), # Optional: add the other AltGeoViz paper authors as co-recipients (safe in admin). + dict(title="People’s Choice Award", organization=None, + date=date(2019, 11, 20), award_type=AwardType.PROJECT_AWARD, + url=NEWS + "homesound-wins-peoples-choice-award/", + description=None, recipients=["Dhruv Jain"], projects=["HomeSound"]), + dict(title="People’s Choice Award", organization="UW Allen School Annual Research Day", + date=date(2018, 11, 2), award_type=AwardType.PROJECT_AWARD, + url=NEWS + "ar-captioning-wins-the-peoples-choice-award-in-allen-school-annual-research-day/", + description=None, recipients=["Dhruv Jain"], projects=["AR Captioning"]), + dict(title="Facilitators’ Choice Award, NSF STEM Video Showcase", + organization="NSF STEM for All Video Showcase", date=date(2016, 5, 1), + award_type=AwardType.PROJECT_AWARD, url=None, + description="Awarded to 13 of 156 video submissions (8.3%).", + recipients=[], projects=["BodyVis"]), + dict(title="Madrona Innovation Prize", organization="Allen School Industry Affiliates", + date=date(2019, 10, 1), award_type=AwardType.PROJECT_AWARD, url=None, + description=None, recipients=["Dhruv Jain"], projects=["HomeSound"]), # Optional: date approximate; add the other HomeSound paper authors as co-recipients (safe in admin). +] + + +class Command(BaseCommand): + help = ("Idempotently import the people/project/faculty awards backfilled from the " + "news archive + CV (see docs/plans/awards-content-audit.md). Safe to re-run.") + + def add_arguments(self, parser): + parser.add_argument("--dry-run", action="store_true", + help="Report what would change without writing to the DB.") + + def handle(self, *args, **options): + dry_run = options["dry_run"] + created = skipped = 0 + + with transaction.atomic(): + for entry in ENTRIES: + if Award.objects.filter(title=entry["title"], date=entry["date"]).exists(): + self.stdout.write(f" skip (exists): {entry['title']} ({entry['date'].year})") + skipped += 1 + continue + + people = self._resolve_people(entry["recipients"]) + projects = self._resolve_projects(entry["projects"]) + if not people and not projects: + self.stderr.write(self.style.WARNING( + f" SKIP (no honorees resolved): {entry['title']} ({entry['date'].year})")) + continue + + self.stdout.write(self.style.SUCCESS( + f" create: {entry['title']} ({entry['date'].year}) " + f"-> {[p.get_full_name() for p in people] + [pr.name for pr in projects]}")) + created += 1 + if dry_run: + continue + + award = Award.objects.create( + title=entry["title"], organization=entry["organization"], + date=entry["date"], award_type=entry["award_type"], + url=entry["url"], description=entry["description"]) + if people: + award.recipients.set(people) + if projects: + award.projects.set(projects) + + fixed = self._fix_facilitators_choice(dry_run) + + if dry_run: + self.stdout.write(self.style.WARNING("DRY RUN — rolling back.")) + transaction.set_rollback(True) + + self.stdout.write(self.style.SUCCESS( + f"import_awards done: {created} created, {skipped} skipped, " + f"{fixed} existing row(s) fixed{' (dry run)' if dry_run else ''}.")) + + def _resolve_people(self, names): + """Resolve "First Last" names to Person rows, warning on any miss.""" + people = [] + for name in names: + first, _, last = name.rpartition(" ") + person = Person.objects.filter(first_name=first, last_name=last).first() + if person: + people.append(person) + else: + self.stderr.write(self.style.WARNING(f" ! person not found: {name!r}")) + return people + + def _resolve_projects(self, names): + projects = [] + for name in names: + project = Project.objects.filter(name=name).first() + if project: + projects.append(project) + else: + self.stderr.write(self.style.WARNING(f" ! project not found: {name!r}")) + return projects + + def _fix_facilitators_choice(self, dry_run): + """Data fix (#audit-F): the existing 'Facilitators' Choice' row is dated 2020 + but its '8.7%' description + news confirm it's the 2019 PrototypAR award. + Correct the date and attach the project/recipient. Idempotent: once the + date is 2019 it no longer matches the 2020 filter.""" + stale = Award.objects.filter(award_type=AwardType.PROJECT_AWARD, + title__istartswith="Facilitators", + date__year=2020).first() + if not stale: + return 0 + self.stdout.write(self.style.SUCCESS( + f" fix: '{stale.title}' 2020 -> 2019-05-31 + attach PrototypAR")) + if dry_run: + return 1 + stale.date = date(2019, 5, 31) + stale.save() + project = Project.objects.filter(name="PrototypAR").first() + if project: + stale.projects.add(project) + person = Person.objects.filter(first_name="Seokbin", last_name="Kang").first() + if person: + stale.recipients.add(person) + return 1 diff --git a/website/models/award.py b/website/models/award.py index cc3c7d50..9dc033cf 100644 --- a/website/models/award.py +++ b/website/models/award.py @@ -1,6 +1,9 @@ from django.db import models from sortedm2m.fields import SortedManyToManyField +from website.utils.fileutils import UniquePathAndRename +from website.utils.upload_validators import validate_image_upload + class AwardType(models.TextChoices): """The section an award appears under on the public Awards page. @@ -58,6 +61,20 @@ class Award(models.Model): description = models.TextField(blank=True, null=True) description.help_text = "Optional additional context. HTML is allowed." + # Optional emblem/logo for the left-side "anchor" the Awards page shows next + # to each award (see display_award_snippet.html / get_anchor_kind). It mainly + # serves Faculty Honors, which otherwise fall back to a medal icon; uploading + # one here overrides the per-category default for any award. + badge = models.ImageField(blank=True, null=True, upload_to=UniquePathAndRename("awards", True), + max_length=255, validators=[validate_image_upload]) + badge.help_text = ("Optional emblem/logo shown beside the award (e.g., the awarding " + "organization's logo). Faculty honors otherwise show a medal icon. " + "Student awards default to the recipient's photo and project awards to " + "the project thumbnail; uploading a badge overrides those.") + + badge_alt_text = models.CharField(max_length=255, blank=True, null=True) + badge_alt_text.help_text = "Alt text for the badge image. Defaults to the award title if left blank." + def get_recipient_names(self): """Returns a comma-separated list of recipient names (no middle names).""" return ", ".join(p.get_full_name(include_middle=False) for p in self.recipients.all()) @@ -85,6 +102,52 @@ def get_honorees(self): parts = [self.get_recipient_names(), self.get_project_names()] return ", ".join(part for part in parts if part) + def get_badge_alt_text(self): + """Alt text for the badge image; falls back to the award title.""" + return self.badge_alt_text or self.title + + def get_portrait_person(self): + """First recipient (in sorted order) who has an uploaded photo. + + Drives the Student-award "portrait" anchor on the Awards page. Iterates + recipients.all() rather than filtering so the editor-controlled + SortedManyToManyField order is honored. + """ + for person in self.recipients.all(): + if person.image: + return person + return None + + def get_thumbnail_project(self): + """First publicly-visible project (in sorted order) with a gallery image. + + Drives the Project-award "thumbnail" anchor on the Awards page. + """ + for project in self.get_visible_projects(): + if project.gallery_image: + return project + return None + + def get_anchor_kind(self): + """Which left-side visual the Awards page shows for this award. + + Returns one of: + 'badge' - an uploaded emblem/logo (overrides everything), + 'portrait' - a recipient's photo (Student Awards / PhD Fellowships), + 'thumbnail' - a project's gallery image (Project Awards), + 'medal' - an icon fallback (Faculty Honors, or when the + category-specific image is missing). + """ + if self.badge: + return 'badge' + if self.award_type in (AwardType.STUDENT_AWARD, AwardType.PHD_FELLOWSHIP): + if self.get_portrait_person(): + return 'portrait' + elif self.award_type == AwardType.PROJECT_AWARD: + if self.get_thumbnail_project(): + return 'thumbnail' + return 'medal' + def __str__(self): return f"{self.get_honorees() or 'Unknown'} — {self.title} ({self.date.year})" diff --git a/website/static/website/css/awards.css b/website/static/website/css/awards.css index a309c49c..8c1b98e3 100644 --- a/website/static/website/css/awards.css +++ b/website/static/website/css/awards.css @@ -36,16 +36,153 @@ } /* --------------------------------------------------------------------------- - Award snippet (people & project distinctions). The shared .artifact-* - classes come from base.css; these rules only adjust spacing for this page. + Award card (people & project distinctions). + + Layout: a category-specific visual "anchor" on the left (recipient photo for + student awards, project thumbnail for project awards, medal icon for faculty + honors, or an uploaded badge that overrides any of these) + the award details + on the right, led by a prominent year badge. Markup lives in + display_award_snippet.html; Award.get_anchor_kind() sets the + `.award-card--{kind}` modifier. --------------------------------------------------------------------------- */ -/* Title sits flush at the top of each item. */ -.award .artifact-title { - margin-top: 0; +.award-card { + display: flex; + align-items: flex-start; + gap: var(--space-4); + padding: var(--space-4) 0; + border-bottom: 1px solid var(--color-border); +} + +/* Don't draw a divider under the last award in a section. */ +.award-item:last-child .award-card { + border-bottom: none; +} + +/* ---- Left anchor -------------------------------------------------------- */ +.award-anchor { + flex: 0 0 96px; + width: 96px; + display: flex; + align-items: center; + justify-content: center; +} + +.award-anchor-img { + width: 96px; + height: 96px; + object-fit: cover; + border: 1px solid var(--color-border); +} + +/* Student awards: circular portrait of the recipient. */ +.award-anchor-portrait { + border-radius: 50%; } -/* Small gap above the optional description line. */ -.award .award-description { - margin-top: var(--space-1); +/* Uploaded emblem/logo: show whole (don't crop) and let it sit on the page. */ +.award-anchor-badge { + object-fit: contain; + border: none; +} + +/* Faculty honors: medal icon in a soft circular chip. */ +.award-anchor-medal { + width: 96px; + height: 96px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background-color: var(--color-award-gold); + color: var(--color-white); /* white medal glyph on a gold disc */ + font-size: 2.5rem; +} + +/* Project awards: a wider 5:3 thumbnail (matches project gallery ratio). */ +.award-card--thumbnail .award-anchor { + flex-basis: 150px; + width: 150px; +} + +.award-anchor-thumb { + width: 150px; + height: auto; + border-radius: var(--border-radius-md); +} + +/* ---- Body --------------------------------------------------------------- */ +.award-body { + flex: 1 1 auto; + min-width: 0; +} + +/* Prominent golden-orange year badge. Dark text on --color-award-gold is 6.5:1 (AA). */ +.award-year { + display: inline-block; + margin-bottom: var(--space-2); + padding: 2px var(--space-2); + background-color: var(--color-award-gold); + color: var(--color-text-primary); + border-radius: var(--border-radius-sm); + font-family: var(--font-family-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-bold); + letter-spacing: 0.03em; +} + +.award-title { + margin: 0 0 var(--space-1); + font-size: var(--font-size-lg); + font-weight: var(--font-weight-semibold); + line-height: var(--line-height-tight); +} + +.award-org { + margin: 0 0 var(--space-1); + color: var(--color-text-secondary); + font-style: italic; +} + +.award-recipients { + margin: 0; +} + +.award-recipients a { + color: var(--color-text-primary); + text-decoration: none; +} + +.award-recipients a:hover { + color: var(--color-link-hover); + text-decoration: underline; +} + +.award-description { + margin: var(--space-2) 0 0; + color: var(--color-text-secondary); + font-size: var(--font-size-sm); +} + +/* ---- Small screens: shrink the anchor so the text keeps room. ----------- */ +@media (max-width: 575px) { + .award-anchor, + .award-card--thumbnail .award-anchor { + flex-basis: 64px; + width: 64px; + } + + .award-anchor-img, + .award-anchor-medal { + width: 64px; + height: 64px; + } + + .award-anchor-medal { + font-size: 1.75rem; + } + + .award-anchor-thumb { + width: 64px; + } } \ No newline at end of file diff --git a/website/static/website/css/design-tokens.css b/website/static/website/css/design-tokens.css index fbcc9f37..a78e3e3e 100644 --- a/website/static/website/css/design-tokens.css +++ b/website/static/website/css/design-tokens.css @@ -112,7 +112,11 @@ --color-badge-text-hover: #083854; /* Awards */ - --color-award: #c25059; + --color-award: #c25059; /* legacy red; used by the publications award label */ + + /* Awards page — golden-orange accent (year badge, medal disc). Warm and + distinct from the blue link text; paired with dark text for WCAG AA. */ + --color-award-gold: #e8910c; /* golden orange; 6.5:1 with dark text - AA ✓ */ /* ========================================================================== diff --git a/website/templates/snippets/display_award_snippet.html b/website/templates/snippets/display_award_snippet.html index 0b5a863b..32ead406 100644 --- a/website/templates/snippets/display_award_snippet.html +++ b/website/templates/snippets/display_award_snippet.html @@ -1,35 +1,72 @@ {% comment %} -Renders a single Award (person and/or project recognition). +Renders a single Award (person and/or project recognition) as a card. Expects `award` (an Award instance) in context. -Layout/spacing is handled in awards.css under the `.award` scope, so this +Layout: a category-specific visual "anchor" on the left + the award details on +the right, with a prominent year. The anchor varies by award_type (see +Award.get_anchor_kind): + - Student Award / PhD Fellowship -> recipient's photo (circular portrait) + - Project Award -> project gallery thumbnail + - Faculty Honor (or fallback) -> medal icon + - any award with a `badge` image -> that badge (overrides the above) + +The anchor is purely decorative: every name, project, title, and organization it +could convey is already present as text/links in the body, so the anchor is +marked aria-hidden and its images carry empty alt text to avoid duplicate links +and redundant screen-reader announcements. + +All layout/spacing lives in awards.css under the `.award-card` scope, so this markup stays free of inline styles. {% endcomment %} +{% load static thumbnail %} + +{% with kind=award.get_anchor_kind %} +
-
-

- {% if award.url %} - {{ award.title }} +

+ +
+ {{ award.date|date:"Y" }} + +

+ {% if award.url %} + {{ award.title }} + {% else %} + {{ award.title }} + {% endif %} +

+ + {% if award.organization %}

{{ award.organization }}

{% endif %} + +

+ {% for person in award.recipients.all %}{{ person.get_full_name }}{% if not forloop.last %}, {% endif %}{% endfor %} + + {% if award.recipients.exists and award.get_visible_projects.exists %}, {% endif %} + + {# Only list publicly-visible projects so private projects aren't named here (#1300) #} + {% for project in award.get_visible_projects %}{{ project.name }}{% if not forloop.last %}, {% endif %}{% endfor %} +

+ + {% if award.description %} +

{{ award.description|safe }}

{% endif %} - {% if award.organization %} — {{ award.organization }}{% endif %} -

- -

- {% for person in award.recipients.all %} - {{ person.get_full_name }}{% if not forloop.last %}, {% endif %} - {% endfor %} - - {% if award.recipients.exists and award.get_visible_projects.exists %}, {% endif %} - - {# Only list publicly-visible projects so private projects aren't named here (#1300) #} - {% for project in award.get_visible_projects %} - {{ project.name }}{% if not forloop.last %}, {% endif %} - {% endfor %} -

- - {% if award.description %} - - {% endif %} -
\ No newline at end of file +
+ +
+{% endwith %} diff --git a/website/tests/test_award.py b/website/tests/test_award.py new file mode 100644 index 00000000..67e573ba --- /dev/null +++ b/website/tests/test_award.py @@ -0,0 +1,117 @@ +""" +Regression tests for the Award model's Awards-page display logic (#1294 follow-up: +Awards redesign). + +These pin Award.get_anchor_kind() — which picks the left-side visual the Awards +page shows per award (recipient portrait / project thumbnail / medal icon / +uploaded badge) — and the helpers it relies on, plus a smoke test that the +public Awards page renders the new card markup. The branching is easy to break +when award types or visibility rules change, and it's only reachable through a +real queryset (M2M relations, project visibility), so it belongs here rather +than in a SimpleTestCase. +""" + +from datetime import date + +from django.urls import reverse + +from website.models.award import Award, AwardType +from website.tests.base import DatabaseTestCase +from website.tests.factories import image_upload + + +class AwardAnchorTests(DatabaseTestCase): + """Award.get_anchor_kind() and its supporting helpers.""" + + def make_award(self, award_type, recipients=(), projects=(), **kwargs): + kwargs.setdefault("title", "Test Award") + kwargs.setdefault("date", date(2020, 1, 1)) + award = Award.objects.create(award_type=award_type, **kwargs) + if recipients: + award.recipients.set(recipients) + if projects: + award.projects.set(projects) + return award + + def test_faculty_honor_uses_medal(self): + person = self.make_person() + award = self.make_award(AwardType.FACULTY_HONOR, recipients=[person]) + self.assertEqual(award.get_anchor_kind(), "medal") + + def test_student_award_uses_recipient_portrait(self): + person = self.make_person() # factory pre-sets an image + award = self.make_award(AwardType.STUDENT_AWARD, recipients=[person]) + self.assertEqual(award.get_anchor_kind(), "portrait") + self.assertEqual(award.get_portrait_person(), person) + + def test_phd_fellowship_uses_portrait(self): + person = self.make_person() + award = self.make_award(AwardType.PHD_FELLOWSHIP, recipients=[person]) + self.assertEqual(award.get_anchor_kind(), "portrait") + + def test_student_award_without_a_photo_recipient_falls_back_to_medal(self): + # A student-typed award honoring only a project (no person) has no + # portrait to show, so it must fall back to the medal icon. + project = self.make_project(is_visible=True, with_thumbnail=True) + award = self.make_award(AwardType.STUDENT_AWARD, projects=[project]) + self.assertIsNone(award.get_portrait_person()) + self.assertEqual(award.get_anchor_kind(), "medal") + + def test_project_award_uses_thumbnail(self): + project = self.make_project(is_visible=True, with_thumbnail=True) + award = self.make_award(AwardType.PROJECT_AWARD, projects=[project]) + self.assertEqual(award.get_anchor_kind(), "thumbnail") + self.assertEqual(award.get_thumbnail_project(), project) + + def test_project_award_without_thumbnail_falls_back_to_medal(self): + project = self.make_project(is_visible=True) # no gallery_image + award = self.make_award(AwardType.PROJECT_AWARD, projects=[project]) + self.assertIsNone(award.get_thumbnail_project()) + self.assertEqual(award.get_anchor_kind(), "medal") + + def test_project_award_ignores_private_project_for_thumbnail(self): + # #1300: private projects must not surface on the public Awards page. + project = self.make_project(is_visible=False, with_thumbnail=True) + award = self.make_award(AwardType.PROJECT_AWARD, projects=[project]) + self.assertIsNone(award.get_thumbnail_project()) + self.assertEqual(award.get_anchor_kind(), "medal") + + def test_badge_overrides_category_anchor(self): + # An uploaded badge wins even for a student award with a photographed + # recipient. + person = self.make_person() + award = self.make_award( + AwardType.STUDENT_AWARD, + recipients=[person], + badge=image_upload("badge.gif"), + ) + self.assertEqual(award.get_anchor_kind(), "badge") + + def test_badge_alt_text_defaults_to_title(self): + award = self.make_award( + AwardType.FACULTY_HONOR, + title="NSF CAREER Award", + recipients=[self.make_person()], + ) + self.assertEqual(award.get_badge_alt_text(), "NSF CAREER Award") + award.badge_alt_text = "NSF logo" + self.assertEqual(award.get_badge_alt_text(), "NSF logo") + + +class AwardsPageRenderTests(DatabaseTestCase): + """The public Awards page renders the redesigned card markup.""" + + def test_awards_page_renders_card_with_year_and_anchor_kind(self): + person = self.make_person(first_name="Ada", last_name="Lovelace") + Award.objects.create( + title="A Faculty Honor", + date=date(2017, 5, 1), + award_type=AwardType.FACULTY_HONOR, + ).recipients.set([person]) + + response = self.client.get(reverse("website:awards")) + self.assertEqual(response.status_code, 200) + html = response.content.decode() + self.assertIn("award-card", html) + self.assertIn("award-card--medal", html) # faculty honor -> medal anchor + self.assertIn("2017", html) # prominent year is rendered diff --git a/website/tests/test_import_awards.py b/website/tests/test_import_awards.py new file mode 100644 index 00000000..b5b1f21b --- /dev/null +++ b/website/tests/test_import_awards.py @@ -0,0 +1,74 @@ +""" +Regression tests for the `import_awards` management command (#awards-update). + +The command runs on every container start (docker-entrypoint.sh), so the +guarantees that matter are: it's idempotent, it never crashes on a recipient/ +project name that doesn't resolve, `--dry-run` writes nothing, and it applies +the one in-place data fix (#audit-F) exactly once. +""" + +from datetime import date + +from django.core.management import call_command + +from website.models import Award +from website.models.award import AwardType +from website.tests.base import DatabaseTestCase + + +class ImportAwardsTests(DatabaseTestCase): + + def test_creates_award_for_resolved_recipient(self): + jon = self.make_person(first_name="Jon", last_name="Froehlich") + call_command("import_awards", verbosity=0) + + career = Award.objects.filter(title="NSF CAREER Award", date=date(2017, 2, 14)).first() + self.assertIsNotNone(career) + self.assertEqual(career.award_type, AwardType.FACULTY_HONOR) + self.assertIn(jon, career.recipients.all()) + + def test_is_idempotent(self): + self.make_person(first_name="Jon", last_name="Froehlich") + call_command("import_awards", verbosity=0) + count_after_first = Award.objects.count() + self.assertGreater(count_after_first, 0) + + call_command("import_awards", verbosity=0) + self.assertEqual(Award.objects.count(), count_after_first) + + def test_skips_entries_whose_honorees_do_not_resolve(self): + # No people/projects exist, so every entry lacking a resolvable honoree + # is skipped rather than created empty — and nothing raises. + call_command("import_awards", verbosity=0) + for award in Award.objects.all(): + self.assertTrue( + award.recipients.exists() or award.projects.exists(), + f"{award.title} was created with no honoree", + ) + + def test_dry_run_writes_nothing(self): + self.make_person(first_name="Jon", last_name="Froehlich") + call_command("import_awards", "--dry-run", verbosity=0) + self.assertEqual(Award.objects.count(), 0) + + def test_fixes_stale_facilitators_choice_row(self): + prototypar = self.make_project(name="PrototypAR", is_visible=True) + self.make_person(first_name="Seokbin", last_name="Kang") + stale = Award.objects.create( + title="Facilitators’ Choice Award, NSF Video Showcase", + organization="NSF Video Showcase", + date=date(2020, 5, 20), + award_type=AwardType.PROJECT_AWARD, + description="Awarded to 21 of 242 video submissions (8.7%)", + ) + + call_command("import_awards", verbosity=0) + + stale.refresh_from_db() + self.assertEqual(stale.date, date(2019, 5, 31)) + self.assertIn(prototypar, stale.projects.all()) + + # Running again must not re-touch it (it's no longer dated 2020). + call_command("import_awards", verbosity=0) + stale.refresh_from_db() + self.assertEqual(stale.date, date(2019, 5, 31))