diff --git a/docker-compose.yml b/docker-compose.yml index 0d5bd0bfd..74b0d4031 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,15 @@ services: test: ["CMD", "redis-cli","ping"] interval: 1s + maildev: + image: maildev/maildev:2.1.0 + ports: + - "1080:1080" + - "1025:1025" + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:1080"] + interval: 5s + static: command: bin/static build: diff --git a/fixtures/fellows.json b/fixtures/fellows.json new file mode 100644 index 000000000..4e304d0c4 --- /dev/null +++ b/fixtures/fellows.json @@ -0,0 +1,5774 @@ +[ + { + "model": "nominations.fellow", + "pk": 1, + "fields": { + "name": "Aaron Yankey", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 2, + "fields": { + "name": "Abhijeet Mote", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 3, + "fields": { + "name": "Abigail Afi Gbadago", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 4, + "fields": { + "name": "Abigail Mesrenyame Dogbe", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 5, + "fields": { + "name": "Abhishek Mishra", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 6, + "fields": { + "name": "Adam Johnson", + "year_elected": 2024, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 7, + "fields": { + "name": "Adrian Holovaty", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 8, + "fields": { + "name": "Aidis Stukas", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 9, + "fields": { + "name": "Aisha Bello", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 10, + "fields": { + "name": "Al Sweigart", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 11, + "fields": { + "name": "Alex Gaynor", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 12, + "fields": { + "name": "Alex Martelli", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 13, + "fields": { + "name": "Alex Willmer", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 14, + "fields": { + "name": "Alexander Hendorf", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 15, + "fields": { + "name": "Alexandre Savio", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 16, + "fields": { + "name": "Allison Randal", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 17, + "fields": { + "name": "Alyssa Coghlan", + "year_elected": 2007, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 18, + "fields": { + "name": "Amaury Forgeot d'Arc", + "year_elected": 2008, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 19, + "fields": { + "name": "Amber Brown", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 20, + "fields": { + "name": "Ana Dulce Padovan", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 21, + "fields": { + "name": "Anand Chitipothu", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 22, + "fields": { + "name": "Anand Pillai", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 23, + "fields": { + "name": "Andrew Godwin", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 24, + "fields": { + "name": "Andrew Kuchling", + "year_elected": 2003, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 25, + "fields": { + "name": "Anna Martelli Ravenscroft", + "year_elected": 2006, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 26, + "fields": { + "name": "Anne Gentle", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 27, + "fields": { + "name": "Anthony Baxter", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 28, + "fields": { + "name": "Anthony Scopatz", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 29, + "fields": { + "name": "Anthony Shaw", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 30, + "fields": { + "name": "Anthony Sottile", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 31, + "fields": { + "name": "Antoine Pitrou", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 32, + "fields": { + "name": "Anton Caceres", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 33, + "fields": { + "name": "Antonio Cuni", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 34, + "fields": { + "name": "Anwesha Das", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 35, + "fields": { + "name": "Arc Riley", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 36, + "fields": { + "name": "Archana Vaidheeswaran", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 37, + "fields": { + "name": "Armin Ronacher", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 38, + "fields": { + "name": "Armin Stroß-Radschinski", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 39, + "fields": { + "name": "Artur Czepiel", + "year_elected": 2024, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 40, + "fields": { + "name": "Asheesh Laroia", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 41, + "fields": { + "name": "Audrey Roy", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 42, + "fields": { + "name": "Bae KwonHan", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 43, + "fields": { + "name": "Baptiste Mispelon", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 44, + "fields": { + "name": "Batuhan Taskaya", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 45, + "fields": { + "name": "Barbara Shaurette", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 46, + "fields": { + "name": "Barney Gale", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 47, + "fields": { + "name": "Barry Warsaw", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 48, + "fields": { + "name": "Becky Smith", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 49, + "fields": { + "name": "Belinda Weaver", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 50, + "fields": { + "name": "Ben Bangert", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 51, + "fields": { + "name": "Benjamin Peterson", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 52, + "fields": { + "name": "Benoit Chesneau", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 53, + "fields": { + "name": "Berker Peksag", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 54, + "fields": { + "name": "Bernát Gábor", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 55, + "fields": { + "name": "Brandon Rhodes", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 56, + "fields": { + "name": "Brett Cannon", + "year_elected": 2003, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 57, + "fields": { + "name": "Brian Costlow", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 58, + "fields": { + "name": "Brian Curtin", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 59, + "fields": { + "name": "Brian K. Jones", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 60, + "fields": { + "name": "Brian Zimmer", + "year_elected": 2005, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 61, + "fields": { + "name": "Briana Augenreich", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 62, + "fields": { + "name": "Bruno Oliveira", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 63, + "fields": { + "name": "Bruno Rocha", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 64, + "fields": { + "name": "C Titus Brown", + "year_elected": 2008, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 65, + "fields": { + "name": "Cameron Laird", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 66, + "fields": { + "name": "Carl F. Karsten", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 67, + "fields": { + "name": "Carl Friedrich Bolz", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 68, + "fields": { + "name": "Carl Meyer", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 69, + "fields": { + "name": "Carol Willing", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 70, + "fields": { + "name": "Carlton Gibson", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 71, + "fields": { + "name": "Carrie Anne Philbin", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 72, + "fields": { + "name": "Catherine Devlin", + "year_elected": 2007, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 73, + "fields": { + "name": "Chandan Kumar", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 74, + "fields": { + "name": "Charlie Marsh", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 75, + "fields": { + "name": "Cheuk Ting Ho", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 76, + "fields": { + "name": "Chris Brousseau", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 77, + "fields": { + "name": "Chris Jerdonek", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 78, + "fields": { + "name": "Chris Neugebauer", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 79, + "fields": { + "name": "Chris Withers", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 80, + "fields": { + "name": "Christian Barra", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 81, + "fields": { + "name": "Christian Heimes", + "year_elected": 2008, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 82, + "fields": { + "name": "Christian Scholz", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 83, + "fields": { + "name": "Christian Theune", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 84, + "fields": { + "name": "Christian Tismer", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 85, + "fields": { + "name": "Christoph Gohlke", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 86, + "fields": { + "name": "Christopher Armstrong", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 87, + "fields": { + "name": "Christopher Bailey", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 88, + "fields": { + "name": "Christopher MacGowan", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 89, + "fields": { + "name": "Chukwudi Nwachukwu", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 90, + "fields": { + "name": "Claudiu Popa", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 91, + "fields": { + "name": "Cory Benfield", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 92, + "fields": { + "name": "Cristián Danilo Maureira-Fredes", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 93, + "fields": { + "name": "Damien George", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 94, + "fields": { + "name": "Dana Bauer", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 95, + "fields": { + "name": "Daniel Greenfeld", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 96, + "fields": { + "name": "Daniel Pope", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 97, + "fields": { + "name": "Daniele Procida", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 98, + "fields": { + "name": "Danny Adair", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 99, + "fields": { + "name": "Darya Chyzhyk", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 100, + "fields": { + "name": "Dave Forgac", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 101, + "fields": { + "name": "Dave Malcolm", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 102, + "fields": { + "name": "David Goodger", + "year_elected": 2003, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 103, + "fields": { + "name": "David Lord", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 104, + "fields": { + "name": "David Markey", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 105, + "fields": { + "name": "Dawn Wages", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 106, + "fields": { + "name": "Dean Troyer", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 107, + "fields": { + "name": "Débora Azevedo", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 108, + "fields": { + "name": "Denny Perez", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 109, + "fields": { + "name": "Diana Clarke", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 110, + "fields": { + "name": "Dino Viehland", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 111, + "fields": { + "name": "Don Sheu", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 112, + "fields": { + "name": "Donald Beaudry", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 113, + "fields": { + "name": "Donald Stufft", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 114, + "fields": { + "name": "Doug Hellmann", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 115, + "fields": { + "name": "Doug Napoleone", + "year_elected": 2007, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 116, + "fields": { + "name": "Duncan McGreggor", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 117, + "fields": { + "name": "Dustin Ingram", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 118, + "fields": { + "name": "Dusty Phillips", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 119, + "fields": { + "name": "Eduardo Mendes", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 120, + "fields": { + "name": "Elaine Wong", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 121, + "fields": { + "name": "Elana Hashman", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 122, + "fields": { + "name": "Emily Morehouse-Valcarcel", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 123, + "fields": { + "name": "Emmanuelle Gouillart", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 124, + "fields": { + "name": "Eric Holscher", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 125, + "fields": { + "name": "Eric Jones", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 126, + "fields": { + "name": "Eric S. Raymond", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 127, + "fields": { + "name": "Eric Traut", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 128, + "fields": { + "name": "Eric V. Smith", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 129, + "fields": { + "name": "Érico Andrei", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 130, + "fields": { + "name": "Esteban Maya Cadavid", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 131, + "fields": { + "name": "Ee Durbin", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 132, + "fields": { + "name": "Ewa Jodlowska", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 133, + "fields": { + "name": "Eyitemi Egbejule", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 134, + "fields": { + "name": "Fabio Pliger", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 135, + "fields": { + "name": "Facundo Batista", + "year_elected": 2005, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 136, + "fields": { + "name": "Felipe de Morais", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 137, + "fields": { + "name": "Fernando Masanori Ashikaga", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 138, + "fields": { + "name": "Fernando Perez", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 139, + "fields": { + "name": "Filip Kłębczyk", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 140, + "fields": { + "name": "Finn Bock", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 141, + "fields": { + "name": "Fiorella De Luca", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 142, + "fields": { + "name": "Florian Bruhin", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 143, + "fields": { + "name": "Francisco Palm", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 144, + "fields": { + "name": "Frank Wierzbicki", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 145, + "fields": { + "name": "Frank Wiles", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 146, + "fields": { + "name": "Fred L. Drake, Jr.", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 147, + "fields": { + "name": "Gael Varoquaux", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 148, + "fields": { + "name": "Gautier Hayoun", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 149, + "fields": { + "name": "Gavin M. Roy", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 150, + "fields": { + "name": "Georg Brandl", + "year_elected": 2006, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 151, + "fields": { + "name": "George Paci", + "year_elected": 2006, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 152, + "fields": { + "name": "Georgi Ker", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 153, + "fields": { + "name": "Giles Thomas", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 154, + "fields": { + "name": "Gina Häußge", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 155, + "fields": { + "name": "Giovanni Bajo", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 156, + "fields": { + "name": "Glyph Lefkowitz", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 157, + "fields": { + "name": "Graham Dumpleton", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 158, + "fields": { + "name": "Greg Ewing", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 159, + "fields": { + "name": "Greg Stein", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 160, + "fields": { + "name": "Greg Ward", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "converted to emeritus in 2008, re-activated in 2013", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 161, + "fields": { + "name": "Greg Wilson", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 162, + "fields": { + "name": "Gregory Smith", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 163, + "fields": { + "name": "Grishma Jena", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 164, + "fields": { + "name": "Guido van Rossum", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 165, + "fields": { + "name": "Gustavo Niemeyer", + "year_elected": 2004, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 166, + "fields": { + "name": "Hamdalah Adetunji", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 167, + "fields": { + "name": "Hanno Schlichting", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 168, + "fields": { + "name": "Harald Armin Massa", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 169, + "fields": { + "name": "Henrique Bastos", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 170, + "fields": { + "name": "Hugo van Kemenade", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 171, + "fields": { + "name": "Humphrey Butau", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 172, + "fields": { + "name": "Hye-Shik Chang", + "year_elected": 2004, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 173, + "fields": { + "name": "Hynek Schlawack", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 174, + "fields": { + "name": "Ian Bicking", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 175, + "fields": { + "name": "Ines Montani", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 176, + "fields": { + "name": "Inessa Pawson", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 177, + "fields": { + "name": "Iqbal Abdullah", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 178, + "fields": { + "name": "Ivan Levkivskyi", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 179, + "fields": { + "name": "Ivaylo Bachvarov", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 180, + "fields": { + "name": "Ivy Fung Oi Wei", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 181, + "fields": { + "name": "Jack Jansen", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 182, + "fields": { + "name": "Jackie Kazil", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 183, + "fields": { + "name": "Jacob Hallén", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 184, + "fields": { + "name": "Jacob Kaplan-Moss", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 185, + "fields": { + "name": "Jakub Baláš", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 186, + "fields": { + "name": "James Abel", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 187, + "fields": { + "name": "James Bennett", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 188, + "fields": { + "name": "James Blair", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 189, + "fields": { + "name": "James Tauber", + "year_elected": 2008, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 190, + "fields": { + "name": "Jan Ulrich Hasecke", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 191, + "fields": { + "name": "Jannis Leidel", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 192, + "fields": { + "name": "Jason Pellerin", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 193, + "fields": { + "name": "Jason Tishler", + "year_elected": 2003, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 194, + "fields": { + "name": "Jay Miller", + "year_elected": 2024, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 195, + "fields": { + "name": "Jean-Paul Calderone", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 196, + "fields": { + "name": "Jeff Elkner", + "year_elected": 2004, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 197, + "fields": { + "name": "Jeff Reback", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 198, + "fields": { + "name": "Jeff Rush", + "year_elected": 2007, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 199, + "fields": { + "name": "Jeff Triplett", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 200, + "fields": { + "name": "Jelle Zijlstra", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 201, + "fields": { + "name": "Jeremy Dunck", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 202, + "fields": { + "name": "Jeremy Hylton", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 203, + "fields": { + "name": "Jesse Noller", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 204, + "fields": { + "name": "Jessica McKellar", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 205, + "fields": { + "name": "Jim Baker", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 206, + "fields": { + "name": "Jimena Escobar Bermúdez", + "year_elected": 2024, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 207, + "fields": { + "name": "Jim Fulton", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 208, + "fields": { + "name": "Jim Hugunin", + "year_elected": 2006, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 209, + "fields": { + "name": "João Sebastião de Oliveira Bueno", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 210, + "fields": { + "name": "Joe Banks", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 211, + "fields": { + "name": "John Roa", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 212, + "fields": { + "name": "John Hawley", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 213, + "fields": { + "name": "Jonathan Hartley", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 214, + "fields": { + "name": "Jonathan LaCour", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 215, + "fields": { + "name": "Jon Banafato", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 216, + "fields": { + "name": "Joris Van den Bossche", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 217, + "fields": { + "name": "Josef Heinen", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 218, + "fields": { + "name": "Joshua McKenty", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 219, + "fields": { + "name": "Juan Luis Cano", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 220, + "fields": { + "name": "Jukka Lehtosalo", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 221, + "fields": { + "name": "Julia Duimovich", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 222, + "fields": { + "name": "Julien Palard", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 223, + "fields": { + "name": "Jürgen Gmach", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 224, + "fields": { + "name": "Just van Rossum", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 225, + "fields": { + "name": "Ka-Ping Yee", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 226, + "fields": { + "name": "Kamon Ayeva", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 227, + "fields": { + "name": "Karen Dalton", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 228, + "fields": { + "name": "Karolina Ladino", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 229, + "fields": { + "name": "Katia Lira", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 230, + "fields": { + "name": "Katie Cunningham", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 231, + "fields": { + "name": "Katie McLaughlin", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 232, + "fields": { + "name": "Ken Manheimer", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 233, + "fields": { + "name": "Kenneth Love", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 234, + "fields": { + "name": "Kenneth Reitz", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 235, + "fields": { + "name": "Kevin Altis", + "year_elected": 2003, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 236, + "fields": { + "name": "Kevin O'Brien", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 237, + "fields": { + "name": "Kirby Urner", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 238, + "fields": { + "name": "Kristian Glass", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 239, + "fields": { + "name": "Kojo Idrissa", + "year_elected": 2024, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 240, + "fields": { + "name": "Kurt B. Kaiser", + "year_elected": 2004, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 241, + "fields": { + "name": "Kushal Das", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 242, + "fields": { + "name": "Laís Carvalho", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 243, + "fields": { + "name": "Lance Ellinghaus", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 244, + "fields": { + "name": "Larry Hastings", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 245, + "fields": { + "name": "Laura Cassell", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 246, + "fields": { + "name": "Laurens Van Houtven", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 247, + "fields": { + "name": "Leah Silen", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 248, + "fields": { + "name": "Leah Wasser", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 249, + "fields": { + "name": "Leandro Enrique Colombo Viña", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 250, + "fields": { + "name": "Lennart Regebro", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 251, + "fields": { + "name": "Leon Sandøy", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 252, + "fields": { + "name": "Leonard Richardson", + "year_elected": 2024, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 253, + "fields": { + "name": "Lorena Mesa", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 254, + "fields": { + "name": "Luciano Ramalho", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 255, + "fields": { + "name": "Łukasz Langa", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 256, + "fields": { + "name": "Lynn Root", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 257, + "fields": { + "name": "Maaya Ishida", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 258, + "fields": { + "name": "Mabel Delgado", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 259, + "fields": { + "name": "Mahmoud Hashemi", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 260, + "fields": { + "name": "Mai Giménez", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 261, + "fields": { + "name": "Manabu Terada", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 262, + "fields": { + "name": "Mannie Young", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 263, + "fields": { + "name": "Manuel Kaufmann", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 264, + "fields": { + "name": "Marc-André Lemburg", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 265, + "fields": { + "name": "Marcelo Elizeche Landó", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 266, + "fields": { + "name": "Marco Rougeth", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 267, + "fields": { + "name": "Mark Smith", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 268, + "fields": { + "name": "Mariano Reingart", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 269, + "fields": { + "name": "Mariatta Wijaya", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 270, + "fields": { + "name": "Mario Corchero", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 271, + "fields": { + "name": "Mário Sérgio Oliveira de Queiroz", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 272, + "fields": { + "name": "Mark Dickinson", + "year_elected": 2008, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 273, + "fields": { + "name": "Mark Hammond", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 274, + "fields": { + "name": "Mark Lutz", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 275, + "fields": { + "name": "Mark McLoughlin", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 276, + "fields": { + "name": "Mark Ramm", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 277, + "fields": { + "name": "Marlene Mhangami", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 278, + "fields": { + "name": "Martijn Faassen", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 279, + "fields": { + "name": "Martijn Pieters", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 280, + "fields": { + "name": "Martin Aspeli", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 281, + "fields": { + "name": "Martin von Löwis", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 282, + "fields": { + "name": "Mason Egger", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 283, + "fields": { + "name": "Massimo DiPierro", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 284, + "fields": { + "name": "Mathieu Leduc-Hamel", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 285, + "fields": { + "name": "Matt Lebrun", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 286, + "fields": { + "name": "Matteo Benci", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 287, + "fields": { + "name": "Matthew Dixon Cowles", + "year_elected": 2003, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 288, + "fields": { + "name": "Matthew Lagoe", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 289, + "fields": { + "name": "Matthias Klose", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 290, + "fields": { + "name": "Melissa Weber Mendonça", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 291, + "fields": { + "name": "Mia Bajić", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 292, + "fields": { + "name": "Micaela Reyes", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 293, + "fields": { + "name": "Michael Bayer", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 294, + "fields": { + "name": "Michael Hudson", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 295, + "fields": { + "name": "Michael Iyanda", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 296, + "fields": { + "name": "Michael Kennedy", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 297, + "fields": { + "name": "Michael Sparks", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 298, + "fields": { + "name": "Michael J. Sullivan", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 299, + "fields": { + "name": "Michael Young", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 300, + "fields": { + "name": "Michelle Rowley", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 301, + "fields": { + "name": "Mike Driscoll", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 302, + "fields": { + "name": "Mike Fletcher", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 303, + "fields": { + "name": "Mike McLay", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 304, + "fields": { + "name": "Mike Müller", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 305, + "fields": { + "name": "Mike Olson", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 306, + "fields": { + "name": "Mike Orr", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 307, + "fields": { + "name": "Mike Pirnat", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 308, + "fields": { + "name": "Miguel Grinberg", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 309, + "fields": { + "name": "Miroslav Šedivý", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 310, + "fields": { + "name": "Monty Taylor", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 311, + "fields": { + "name": "Moshe Zadka", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 312, + "fields": { + "name": "Naomi Ceder", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 313, + "fields": { + "name": "Nathaniel Smith", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 314, + "fields": { + "name": "Neal Norwitz", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 315, + "fields": { + "name": "Ned Batchelder", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 316, + "fields": { + "name": "Ned Deily", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 317, + "fields": { + "name": "Neil Schemenauer", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 318, + "fields": { + "name": "Ng Swee Meng", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 319, + "fields": { + "name": "Ngazetungue Muheue", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 320, + "fields": { + "name": "Nick Barcet", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 321, + "fields": { + "name": "Nicolas Chauvat", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 322, + "fields": { + "name": "Nicolás Demarchi", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 323, + "fields": { + "name": "Nicolas Laurance", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 324, + "fields": { + "name": "Nicole Harris", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 325, + "fields": { + "name": "Nikita Sobolev", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 326, + "fields": { + "name": "Nilo Ney Coutinho Menezes", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 327, + "fields": { + "name": "Noah Alorwu", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 328, + "fields": { + "name": "Noah Kantrowitz", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 329, + "fields": { + "name": "Noufal Ibrahim", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 330, + "fields": { + "name": "Ola Sendecka", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 331, + "fields": { + "name": "Ola Sitarska", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 332, + "fields": { + "name": "Olivier Grisel", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 333, + "fields": { + "name": "Osvaldo Santana", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 334, + "fields": { + "name": "Pablo Galindo Salgado", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 335, + "fields": { + "name": "Pablo Rivera", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 336, + "fields": { + "name": "Park Hyun-woo", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 337, + "fields": { + "name": "Patrick Arminio", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 338, + "fields": { + "name": "Paul Everitt", + "year_elected": 2006, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 339, + "fields": { + "name": "Paul Kehrer", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 340, + "fields": { + "name": "Paul McGuire", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 341, + "fields": { + "name": "Paul McMillan", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 342, + "fields": { + "name": "Paolo Melchiorre", + "year_elected": 2024, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 343, + "fields": { + "name": "Paulo Nuin", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 344, + "fields": { + "name": "Peter Inglesby", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 345, + "fields": { + "name": "Peter Kropf", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 346, + "fields": { + "name": "Peter Schneider-Kamp", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 347, + "fields": { + "name": "Peter Wang", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 348, + "fields": { + "name": "Philip James", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 349, + "fields": { + "name": "Philip Jenvey", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 350, + "fields": { + "name": "Philip Jones", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 351, + "fields": { + "name": "Prabhu Ramachandran", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 352, + "fields": { + "name": "Pradyun Gedam", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 353, + "fields": { + "name": "Quentin Wright", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 354, + "fields": { + "name": "David Murray", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 355, + "fields": { + "name": "Ralph Green", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 356, + "fields": { + "name": "Ram Rachum", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 357, + "fields": { + "name": "Rami Chowdhury", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 358, + "fields": { + "name": "Raquel Dou", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 359, + "fields": { + "name": "Reimar Bauer", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 360, + "fields": { + "name": "Reshama Shaikh", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 361, + "fields": { + "name": "Richard Jones", + "year_elected": 2003, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 362, + "fields": { + "name": "Richard Kellner", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 363, + "fields": { + "name": "Richard Taylor", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 364, + "fields": { + "name": "Rick Copeland", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 365, + "fields": { + "name": "Rizky Ariestiyansyah", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 366, + "fields": { + "name": "Robert Collins", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 367, + "fields": { + "name": "Robert Kern", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 368, + "fields": { + "name": "Robin Dunn", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 369, + "fields": { + "name": "Ronald Oussoren", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 370, + "fields": { + "name": "Roy Hyunjin Han", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 371, + "fields": { + "name": "Ruben Orduz", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 372, + "fields": { + "name": "Russell Keith-Magee", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 373, + "fields": { + "name": "Sage Sharp", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 374, + "fields": { + "name": "Sammy Fung", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 375, + "fields": { + "name": "Samuel Colvin", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 376, + "fields": { + "name": "Samuele Pedroni", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 377, + "fields": { + "name": "Saptak Sengupta", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 378, + "fields": { + "name": "Sarah Kaiser", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 379, + "fields": { + "name": "Sean Reifschneider", + "year_elected": 2007, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 380, + "fields": { + "name": "Sebastiaan Zeeff", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 381, + "fields": { + "name": "Sebastian Vetter", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 382, + "fields": { + "name": "Selena Deckelman", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 383, + "fields": { + "name": "Serhiy Storchaka", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 384, + "fields": { + "name": "Seth Michael Larson", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 385, + "fields": { + "name": "Simon Cross", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 386, + "fields": { + "name": "Simon Willison", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 387, + "fields": { + "name": "Sjoerd Mullender", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 388, + "fields": { + "name": "Soon Seng Goh", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 389, + "fields": { + "name": "Soong Chee Gi", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 390, + "fields": { + "name": "Stefan Behnel", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 391, + "fields": { + "name": "Stefan van der Walt", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 392, + "fields": { + "name": "Stephan Deibel", + "year_elected": 2003, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 393, + "fields": { + "name": "Stephane Wirtel", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 394, + "fields": { + "name": "Stephen Hawkes", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 395, + "fields": { + "name": "Stephen Thorne", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 396, + "fields": { + "name": "Steven d’Aprano", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 397, + "fields": { + "name": "Tania Allard", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 398, + "fields": { + "name": "Tatiana Andrea Delgadillo Garzofino", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 399, + "fields": { + "name": "Ted Pollari", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 400, + "fields": { + "name": "Tereza Iofciu", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 401, + "fields": { + "name": "Terri Oda", + "year_elected": 2013, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 402, + "fields": { + "name": "Terry Peppers", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 403, + "fields": { + "name": "Terry Reedy", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 404, + "fields": { + "name": "Tetsuya Morimoto", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 405, + "fields": { + "name": "Thea Flowers", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 406, + "fields": { + "name": "Thierry Carrez", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 407, + "fields": { + "name": "Thomas A Caswell", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 408, + "fields": { + "name": "Thomas Waldmann", + "year_elected": 2009, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 409, + "fields": { + "name": "Thomas Wouters", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 410, + "fields": { + "name": "Tim Ansell", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 411, + "fields": { + "name": "Tim Couper", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 412, + "fields": { + "name": "Tim Golden", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 413, + "fields": { + "name": "Tim Peters", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 414, + "fields": { + "name": "Tom Augspurger", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 415, + "fields": { + "name": "Tom Christie", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 416, + "fields": { + "name": "Tom Viner", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 417, + "fields": { + "name": "Travis Oliphant", + "year_elected": 2006, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 418, + "fields": { + "name": "Trent Mick", + "year_elected": 2001, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 419, + "fields": { + "name": "Tres Seaver", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 420, + "fields": { + "name": "Trevor Toenjes", + "year_elected": 2004, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 421, + "fields": { + "name": "Trey Hunner", + "year_elected": 2024, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 422, + "fields": { + "name": "Uche Ogbuji", + "year_elected": 2002, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 423, + "fields": { + "name": "Valentin Dombrovsky", + "year_elected": 2019, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 424, + "fields": { + "name": "Van Lindberg", + "year_elected": 2008, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 425, + "fields": { + "name": "Vasudev Ram", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 426, + "fields": { + "name": "Velda Kiara", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 427, + "fields": { + "name": "Vicky Twomey-Lee", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 428, + "fields": { + "name": "Victor Stinner", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 429, + "fields": { + "name": "Vinay Sajip", + "year_elected": 2003, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 430, + "fields": { + "name": "Vish Ishaya", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 431, + "fields": { + "name": "Walter Dörwald", + "year_elected": 2003, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 432, + "fields": { + "name": "Wes McKinney", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 433, + "fields": { + "name": "Wesley Chun", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 434, + "fields": { + "name": "Wilfredo Sanchez Vega", + "year_elected": 2012, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 435, + "fields": { + "name": "Will McGugan", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 436, + "fields": { + "name": "William Vincent", + "year_elected": 2025, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 437, + "fields": { + "name": "Winnie Ke", + "year_elected": 2024, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 438, + "fields": { + "name": "Yamila Moreno", + "year_elected": 2017, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 439, + "fields": { + "name": "Yannick Gingras", + "year_elected": 2011, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 440, + "fields": { + "name": "Yifei Wang", + "year_elected": 2023, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 441, + "fields": { + "name": "Younggun Kim", + "year_elected": 2020, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 442, + "fields": { + "name": "Yung-Yu Chen", + "year_elected": 2022, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 443, + "fields": { + "name": "Yury Selivanov", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 444, + "fields": { + "name": "Zac Hatfield-Dodds", + "year_elected": 2021, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 445, + "fields": { + "name": "Zachary Ware", + "year_elected": 2018, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 446, + "fields": { + "name": "Zeth Green", + "year_elected": 2010, + "status": "active", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 447, + "fields": { + "name": "David Abrahams", + "year_elected": 2002, + "status": "emeritus", + "emeritus_year": 2008, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 448, + "fields": { + "name": "Paul Boddie", + "year_elected": 2010, + "status": "emeritus", + "emeritus_year": 2015, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 449, + "fields": { + "name": "Paul F. Dubois", + "year_elected": 2002, + "status": "emeritus", + "emeritus_year": 2008, + "notes": "Paul Dubois was an original contributor to Numerical Python, and its coordinator for five years. Paul also hosted the Fourth International Python Conference in 1996.", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 450, + "fields": { + "name": "Lars Marius Garshol", + "year_elected": 2001, + "status": "emeritus", + "emeritus_year": 2005, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 451, + "fields": { + "name": "Charles G. Waldman", + "year_elected": 2001, + "status": "emeritus", + "emeritus_year": 2005, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 452, + "fields": { + "name": "Skip Montanaro", + "year_elected": 2001, + "status": "emeritus", + "emeritus_year": 2008, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 453, + "fields": { + "name": "Sam Rushing", + "year_elected": 2002, + "status": "emeritus", + "emeritus_year": 2008, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 454, + "fields": { + "name": "Danny Yoo", + "year_elected": 2004, + "status": "emeritus", + "emeritus_year": 2008, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 455, + "fields": { + "name": "Thomas Heller", + "year_elected": 2001, + "status": "emeritus", + "emeritus_year": 2009, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 456, + "fields": { + "name": "Neil Hodgson", + "year_elected": 2002, + "status": "emeritus", + "emeritus_year": 2009, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 457, + "fields": { + "name": "Armin Rigo", + "year_elected": 2004, + "status": "emeritus", + "emeritus_year": 2010, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 458, + "fields": { + "name": "David Ascher", + "year_elected": 2001, + "status": "emeritus", + "emeritus_year": 2011, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 459, + "fields": { + "name": "Steven Bethard", + "year_elected": 2007, + "status": "emeritus", + "emeritus_year": 2011, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 460, + "fields": { + "name": "Maciej Fijalkowski", + "year_elected": 2011, + "status": "emeritus", + "emeritus_year": 2012, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 461, + "fields": { + "name": "Paul Prescod", + "year_elected": 2001, + "status": "emeritus", + "emeritus_year": 2013, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 462, + "fields": { + "name": "André Roberge", + "year_elected": 2010, + "status": "emeritus", + "emeritus_year": 2013, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 463, + "fields": { + "name": "Tarek Ziadé", + "year_elected": 2010, + "status": "emeritus", + "emeritus_year": 2013, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 464, + "fields": { + "name": "Gloria W. Jacobs", + "year_elected": 2010, + "status": "emeritus", + "emeritus_year": 2013, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 465, + "fields": { + "name": "Holger Krekel", + "year_elected": 2010, + "status": "emeritus", + "emeritus_year": 2018, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 466, + "fields": { + "name": "Nicholas H. Tollervey", + "year_elected": 2012, + "status": "emeritus", + "emeritus_year": 2019, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 467, + "fields": { + "name": "Steve Holden", + "year_elected": 2003, + "status": "emeritus", + "emeritus_year": 2020, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 468, + "fields": { + "name": "David M. Beazley", + "year_elected": 2002, + "status": "emeritus", + "emeritus_year": 2020, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 469, + "fields": { + "name": "Raymond Hettinger", + "year_elected": 2003, + "status": "emeritus", + "emeritus_year": 2020, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 470, + "fields": { + "name": "Jack Diederich", + "year_elected": 2010, + "status": "emeritus", + "emeritus_year": 2020, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 471, + "fields": { + "name": "Laura Creighton", + "year_elected": 2007, + "status": "emeritus", + "emeritus_year": 2021, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 472, + "fields": { + "name": "David Mertz", + "year_elected": 2008, + "status": "emeritus", + "emeritus_year": 2024, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 473, + "fields": { + "name": "Chris McDonough", + "year_elected": 2010, + "status": "emeritus", + "emeritus_year": 2024, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 474, + "fields": { + "name": "Marc Garcia", + "year_elected": 2018, + "status": "emeritus", + "emeritus_year": 2024, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 475, + "fields": { + "name": "Andrew Dalke", + "year_elected": 2004, + "status": "emeritus", + "emeritus_year": 2025, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 476, + "fields": { + "name": "Aahz", + "year_elected": 2002, + "status": "deceased", + "emeritus_year": null, + "notes": "emeritus since 2013", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 477, + "fields": { + "name": "Fredrik Lundh", + "year_elected": 2001, + "status": "deceased", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 478, + "fields": { + "name": "James Lopeman", + "year_elected": 2022, + "status": "deceased", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 479, + "fields": { + "name": "John Pinner", + "year_elected": 2008, + "status": "deceased", + "emeritus_year": null, + "notes": "member from 2008-2015", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 480, + "fields": { + "name": "Malcolm Tredinnick", + "year_elected": 2009, + "status": "deceased", + "emeritus_year": null, + "notes": "", + "user": null + } + }, + { + "model": "nominations.fellow", + "pk": 481, + "fields": { + "name": "Michael Foord", + "year_elected": 2009, + "status": "deceased", + "emeritus_year": null, + "notes": "", + "user": null + } + } +] diff --git a/nominations/admin.py b/nominations/admin.py index 07e516488..73d5688bf 100644 --- a/nominations/admin.py +++ b/nominations/admin.py @@ -2,7 +2,15 @@ from django.db.models.functions import Lower -from nominations.models import Election, Nominee, Nomination +from nominations.models import ( + Election, + Fellow, + FellowNomination, + FellowNominationRound, + FellowNominationVote, + Nomination, + Nominee, +) @admin.register(Election) @@ -29,3 +37,41 @@ class NominationAdmin(admin.ModelAdmin): def get_ordering(self, request): return ['election', Lower('nominee__user__last_name')] + + +@admin.register(Fellow) +class FellowAdmin(admin.ModelAdmin): + list_display = ("name", "year_elected", "status", "emeritus_year") + list_filter = ("status", "year_elected") + search_fields = ("name",) + raw_id_fields = ("user",) + + +@admin.register(FellowNominationRound) +class FellowNominationRoundAdmin(admin.ModelAdmin): + list_display = ("__str__", "quarter_start", "quarter_end", "nominations_cutoff", "is_open") + list_filter = ("is_open", "year") + readonly_fields = ("slug",) + + +@admin.register(FellowNomination) +class FellowNominationAdmin(admin.ModelAdmin): + list_display = ( + "nominee_name", + "nominator", + "nomination_round", + "status", + "nominee_is_fellow_at_submission", + "created", + ) + list_filter = ("status", "nomination_round", "nominee_is_fellow_at_submission") + search_fields = ("nominee_name", "nominee_email", "nominator__username") + raw_id_fields = ("nominator", "nominee_user") + readonly_fields = ("created", "updated", "creator", "last_modified_by") + + +@admin.register(FellowNominationVote) +class FellowNominationVoteAdmin(admin.ModelAdmin): + list_display = ("nomination", "voter", "vote", "voted_at") + list_filter = ("vote",) + raw_id_fields = ("nomination", "voter") diff --git a/nominations/forms.py b/nominations/forms.py index 4a221fc2f..b36151608 100644 --- a/nominations/forms.py +++ b/nominations/forms.py @@ -1,9 +1,16 @@ +import datetime + from django import forms from django.utils.safestring import mark_safe from markupfield.widgets import MarkupTextarea -from .models import Nomination +from nominations.models import ( + FellowNomination, + FellowNominationRound, + FellowNominationVote, + Nomination, +) class NominationForm(forms.ModelForm): @@ -62,3 +69,215 @@ class Meta: help_texts = { "accepted": "If selected, this nomination will be considered accepted and displayed once nominations are public.", } + + +class FellowNominationForm(forms.ModelForm): + """Form for submitting a PSF Fellow nomination.""" + + class Meta: + model = FellowNomination + fields = ( + "nominee_name", + "nominee_email", + "nomination_statement", + ) + widgets = { + "nomination_statement": MarkupTextarea(), + } + help_texts = { + "nominee_name": "Full name of the person you are nominating.", + "nominee_email": "Email address for the person you are nominating.", + "nomination_statement": "Why should this person be recognized as a PSF Fellow? Minimum 120 characters. Markdown supported.", + } + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request", None) + super().__init__(*args, **kwargs) + + def clean_nomination_statement(self): + statement = self.cleaned_data["nomination_statement"] + if len(statement.strip()) < 120: + raise forms.ValidationError( + "Please provide a more detailed nomination statement (at least 120 characters)." + ) + return statement + + def clean_nominee_email(self): + email = self.cleaned_data["nominee_email"] + if self.request and self.request.user.is_authenticated: + if email.lower() == self.request.user.email.lower(): + raise forms.ValidationError( + "You cannot nominate yourself for PSF Fellow membership." + ) + # Prevent duplicate nominations for the same person in the current + # open round. The round FK is set in form_valid, but we can look up + # the current open round here to give early feedback. + current_round = FellowNominationRound.objects.filter(is_open=True).first() + if current_round: + if FellowNomination.objects.filter( + nominee_email__iexact=email, + nomination_round=current_round, + ).exists(): + raise forms.ValidationError( + "This person has already been nominated for the current round." + ) + return email + + +class FellowNominationRoundForm(forms.ModelForm): + """Admin form for creating/editing Fellow nomination rounds. + + Auto-populates date fields from year + quarter when dates are not + explicitly provided, following the WG Charter schedule: + - Nominations cutoff: 20th of month 2 + - Review start: same as nominations cutoff + - Review end: 20th of month 3 + """ + + # Quarter start/end date ranges per quarter number + QUARTER_DATES = { + FellowNominationRound.Quarter.Q1: { + "quarter_start": (1, 1), + "quarter_end": (3, 31), + "nominations_cutoff": (2, 20), + "review_end": (3, 20), + }, + FellowNominationRound.Quarter.Q2: { + "quarter_start": (4, 1), + "quarter_end": (6, 30), + "nominations_cutoff": (5, 20), + "review_end": (6, 20), + }, + FellowNominationRound.Quarter.Q3: { + "quarter_start": (7, 1), + "quarter_end": (9, 30), + "nominations_cutoff": (8, 20), + "review_end": (9, 20), + }, + FellowNominationRound.Quarter.Q4: { + "quarter_start": (10, 1), + "quarter_end": (12, 31), + "nominations_cutoff": (11, 20), + "review_end": (12, 20), + }, + } + + class Meta: + model = FellowNominationRound + fields = ( + "year", + "quarter", + "quarter_start", + "quarter_end", + "nominations_cutoff", + "review_start", + "review_end", + "is_open", + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.instance.pk: + # Prevent changing year/quarter on existing rounds — doing so + # would break the slug and FK relationships. disabled=True + # ensures the value is also ignored on POST (tamper-proof). + self.fields["year"].disabled = True + self.fields["quarter"].disabled = True + elif not self.initial.get("year"): + # Default year to current year on create + self.fields["year"].initial = datetime.date.today().year + # Date fields are optional — auto-populated from year+quarter in clean() + for field_name in ("quarter_start", "quarter_end", "nominations_cutoff", + "review_start", "review_end"): + self.fields[field_name].required = False + + def clean(self): + cleaned_data = super().clean() + year = cleaned_data.get("year") + quarter = cleaned_data.get("quarter") + + if year and quarter: + dates = self.QUARTER_DATES.get(quarter) + if dates: + # Auto-populate dates from year + quarter when not provided + if not cleaned_data.get("quarter_start"): + month, day = dates["quarter_start"] + cleaned_data["quarter_start"] = datetime.date(year, month, day) + + if not cleaned_data.get("quarter_end"): + month, day = dates["quarter_end"] + cleaned_data["quarter_end"] = datetime.date(year, month, day) + + if not cleaned_data.get("nominations_cutoff"): + month, day = dates["nominations_cutoff"] + cleaned_data["nominations_cutoff"] = datetime.date( + year, month, day + ) + + if not cleaned_data.get("review_start"): + # review_start == nominations_cutoff per WG Charter + cleaned_data["review_start"] = cleaned_data.get( + "nominations_cutoff" + ) + + if not cleaned_data.get("review_end"): + month, day = dates["review_end"] + cleaned_data["review_end"] = datetime.date(year, month, day) + + # Validate date ordering + quarter_start = cleaned_data.get("quarter_start") + quarter_end = cleaned_data.get("quarter_end") + review_start = cleaned_data.get("review_start") + nominations_cutoff = cleaned_data.get("nominations_cutoff") + + if quarter_start and quarter_end and quarter_end <= quarter_start: + raise forms.ValidationError( + "Quarter end date must be after quarter start date." + ) + + if review_start and nominations_cutoff and review_start != nominations_cutoff: + raise forms.ValidationError( + "Review start date must equal the nominations cutoff date." + ) + + return cleaned_data + + +class FellowNominationManageForm(forms.ModelForm): + """Admin/WG form for managing a Fellow nomination (full edit).""" + + class Meta: + model = FellowNomination + fields = ( + "nominee_name", + "nominee_email", + "nomination_statement", + "status", + "nominee_user", + ) + widgets = { + "nomination_statement": MarkupTextarea(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["nominee_user"].required = False + + +class FellowNominationStatusForm(forms.ModelForm): + """Minimal form for updating only the status of a Fellow nomination.""" + + class Meta: + model = FellowNomination + fields = ("status",) + + +class FellowNominationVoteForm(forms.ModelForm): + """Form for WG members to cast a vote on a Fellow nomination.""" + + class Meta: + model = FellowNominationVote + fields = ("vote", "comment") + widgets = { + "vote": forms.RadioSelect, + } diff --git a/nominations/management/__init__.py b/nominations/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nominations/management/commands/__init__.py b/nominations/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nominations/management/commands/close_expired_fellow_nominations.py b/nominations/management/commands/close_expired_fellow_nominations.py new file mode 100644 index 000000000..030f8f1e6 --- /dev/null +++ b/nominations/management/commands/close_expired_fellow_nominations.py @@ -0,0 +1,17 @@ +from django.core.management.base import BaseCommand +from django.utils import timezone + +from nominations.models import FellowNomination + + +class Command(BaseCommand): + help = "Close Fellow nominations that have passed their expiry round." + + def handle(self, *args, **options): + today = timezone.now().date() + expired = FellowNomination.objects.filter( + status__in=[FellowNomination.PENDING, FellowNomination.UNDER_REVIEW], + expiry_round__quarter_end__lt=today, + ) + count = expired.update(status=FellowNomination.NOT_ACCEPTED) + self.stdout.write(self.style.SUCCESS(f"Closed {count} expired Fellow nomination(s).")) diff --git a/nominations/management/commands/create_test_nomination_data.py b/nominations/management/commands/create_test_nomination_data.py new file mode 100644 index 000000000..9af25936b --- /dev/null +++ b/nominations/management/commands/create_test_nomination_data.py @@ -0,0 +1,371 @@ +import datetime + +from django.conf import settings +from django.contrib.auth.models import Group +from django.core.management.base import BaseCommand, CommandError + +from nominations.models import ( + Fellow, + FellowNomination, + FellowNominationRound, + FellowNominationVote, +) +from users.models import User + + +class Command(BaseCommand): + help = "Creates test nomination data for the nominations app (development only)" + + def add_arguments(self, parser): + parser.add_argument( + "--force", + action="store_true", + help="Force execution even in non-DEBUG mode (use with extreme caution)", + ) + + def handle(self, *args, **options): + if not settings.DEBUG and not options["force"]: + raise CommandError( + "This command cannot be run in production (DEBUG=False). " + "This command creates test data and should only be used in development environments." + ) + + self._create_groups_and_users() + self._create_rounds() + self._create_nominations() + self._create_votes() + self._create_fellows() + + self.stdout.write( + self.style.SUCCESS( + f"Created test nomination data: " + f"{FellowNominationRound.objects.count()} rounds, " + f"{FellowNomination.objects.count()} nominations, " + f"{FellowNominationVote.objects.count()} votes, " + f"{Fellow.objects.count()} fellows" + ) + ) + + # -- helpers --------------------------------------------------------------- + + def _get_or_create_user(self, username, first_name, last_name, email=None, is_staff=False): + user, created = User.objects.get_or_create( + username=username, + defaults={ + "first_name": first_name, + "last_name": last_name, + "email": email or f"{username}@example.com", + "is_staff": is_staff, + }, + ) + if created: + user.set_password("password") + user.save() + self.stdout.write(f" Created user: {username}") + return user + + def _get_or_create_round(self, year, quarter, is_open=True): + quarter_dates = { + 1: ( + datetime.date(year, 1, 1), + datetime.date(year, 3, 31), + datetime.date(year, 2, 20), + datetime.date(year, 3, 20), + ), + 2: ( + datetime.date(year, 4, 1), + datetime.date(year, 6, 30), + datetime.date(year, 5, 20), + datetime.date(year, 6, 20), + ), + 3: ( + datetime.date(year, 7, 1), + datetime.date(year, 9, 30), + datetime.date(year, 8, 20), + datetime.date(year, 9, 20), + ), + 4: ( + datetime.date(year, 10, 1), + datetime.date(year, 12, 31), + datetime.date(year, 11, 20), + datetime.date(year, 12, 20), + ), + } + start, end, cutoff, review_end = quarter_dates[quarter] + obj, created = FellowNominationRound.objects.get_or_create( + year=year, + quarter=quarter, + defaults={ + "quarter_start": start, + "quarter_end": end, + "nominations_cutoff": cutoff, + "review_start": cutoff, + "review_end": review_end, + "is_open": is_open, + }, + ) + if created: + self.stdout.write(f" Created round: {obj}") + return obj + + def _create_nomination( + self, + nominator, + nominee_name, + nominee_email, + nomination_round, + status="pending", + expiry_round=None, + nominee_user=None, + nominee_is_fellow_at_submission=False, + ): + return FellowNomination.objects.create( + nominator=nominator, + nominee_name=nominee_name, + nominee_email=nominee_email, + nomination_statement=(f"{nominee_name} has made outstanding contributions to the Python community."), + nomination_round=nomination_round, + status=status, + expiry_round=expiry_round, + nominee_user=nominee_user, + nominee_is_fellow_at_submission=nominee_is_fellow_at_submission, + ) + + def _get_or_create_fellow(self, name, year_elected, status="active", emeritus_year=None, notes="", user=None): + obj, created = Fellow.objects.get_or_create( + name=name, + defaults={ + "year_elected": year_elected, + "status": status, + "emeritus_year": emeritus_year, + "notes": notes, + "user": user, + }, + ) + if created: + self.stdout.write(f" Created fellow: {name}") + return obj + + # -- data creation --------------------------------------------------------- + + def _create_groups_and_users(self): + self.stdout.write("Creating groups and users...") + + self.wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + + self.wg_members = [ + self._get_or_create_user("wg_alice", "Alice", "WGMember", "alice.wg@python.org"), + self._get_or_create_user("wg_bob", "Bob", "Reviewer", "bob.wg@python.org"), + self._get_or_create_user("wg_carol", "Carol", "Evaluator", "carol.wg@python.org"), + self._get_or_create_user("wg_dave", "Dave", "Assessor", "dave.wg@python.org"), + ] + for member in self.wg_members: + member.groups.add(self.wg_group) + + self.staff_user = self._get_or_create_user("staff_admin", "Staff", "Admin", is_staff=True) + self.nominator1 = self._get_or_create_user("nominator1", "Nominator", "One", "nominator1@example.com") + self.nominator2 = self._get_or_create_user("nominator2", "Nominator", "Two", "nominator2@example.com") + + def _create_rounds(self): + self.stdout.write("Creating nomination rounds...") + + self.past_round = self._get_or_create_round(2025, 3, is_open=False) + self.current_round = self._get_or_create_round(2026, 1, is_open=True) + self.future_round = self._get_or_create_round(2026, 2, is_open=False) + self.expiry_round = self._get_or_create_round(2026, 4, is_open=False) + self.old_expiry = self._get_or_create_round(2025, 2, is_open=False) + + def _create_nominations(self): + self.stdout.write("Creating nominations...") + + # Past round (2025-Q3) + self._create_nomination( + self.nominator1, + "Past Accepted One", + "past1@example.com", + self.past_round, + status="accepted", + ) + self._create_nomination( + self.nominator2, + "Past Accepted Two", + "past2@example.com", + self.past_round, + status="accepted", + ) + self._create_nomination( + self.nominator1, + "Past Not Accepted", + "past_na@example.com", + self.past_round, + status="not_accepted", + ) + + # Current round (2026-Q1) — pending + for nominator, name, email in [ + (self.nominator1, "Pending Person One", "pending1@example.com"), + (self.nominator2, "Pending Person Two", "pending2@example.com"), + (self.nominator1, "Pending Person Three", "pending3@example.com"), + ]: + self._create_nomination( + nominator, + name, + email, + self.current_round, + status="pending", + expiry_round=self.expiry_round, + ) + + # Current round — under_review (votes added separately) + self.under_review_majority_yes = self._create_nomination( + self.nominator1, + "Review Majority Yes", + "review_yes@example.com", + self.current_round, + status="under_review", + expiry_round=self.expiry_round, + ) + self.under_review_majority_no = self._create_nomination( + self.nominator2, + "Review Majority No", + "review_no@example.com", + self.current_round, + status="under_review", + expiry_round=self.expiry_round, + ) + self.under_review_tie = self._create_nomination( + self.nominator1, + "Review Tie Vote", + "review_tie@example.com", + self.current_round, + status="under_review", + expiry_round=self.expiry_round, + ) + self.under_review_abstains = self._create_nomination( + self.nominator2, + "Review All Abstain", + "review_abstain@example.com", + self.current_round, + status="under_review", + expiry_round=self.expiry_round, + ) + self.under_review_one_vote = self._create_nomination( + self.nominator1, + "Review One Vote", + "review_onevote@example.com", + self.current_round, + status="under_review", + expiry_round=self.expiry_round, + ) + + # Current round — accepted + self._create_nomination( + self.nominator2, + "Current Accepted", + "current_accepted@example.com", + self.current_round, + status="accepted", + ) + + # Nominee who is already a Fellow + fellow_nominee_user = self._get_or_create_user( + "already_fellow", "Already", "Fellow", "already_fellow@example.com" + ) + self._get_or_create_fellow("Already Fellow", 2020, user=fellow_nominee_user) + self._create_nomination( + self.nominator1, + "Already Fellow", + "already_fellow@example.com", + self.current_round, + status="pending", + expiry_round=self.expiry_round, + nominee_user=fellow_nominee_user, + nominee_is_fellow_at_submission=True, + ) + + # Expired nomination (expiry_round in the past) + self._create_nomination( + self.nominator2, + "Expired Pending", + "expired@example.com", + self.past_round, + status="pending", + expiry_round=self.old_expiry, + ) + + self.stdout.write(f" Created {FellowNomination.objects.count()} nominations") + + def _create_votes(self): + self.stdout.write("Creating votes...") + wg1, wg2, wg3, wg4 = self.wg_members + + # Majority yes (3 yes, 1 no) + for voter, vote, comment in [ + (wg1, "yes", "Strong contributor."), + (wg2, "yes", "Agree."), + (wg3, "yes", "Excellent candidate."), + (wg4, "no", "Need more info."), + ]: + FellowNominationVote.objects.get_or_create( + nomination=self.under_review_majority_yes, + voter=voter, + defaults={"vote": vote, "comment": comment}, + ) + + # Majority no (1 yes, 2 no, 1 abstain) + for voter, vote, comment in [ + (wg1, "yes", "Good work."), + (wg2, "no", "Insufficient contributions."), + (wg3, "no", "Not yet."), + (wg4, "abstain", "Conflict of interest."), + ]: + FellowNominationVote.objects.get_or_create( + nomination=self.under_review_majority_no, + voter=voter, + defaults={"vote": vote, "comment": comment}, + ) + + # Tie (2 yes, 2 no) + for voter, vote in [(wg1, "yes"), (wg2, "yes"), (wg3, "no"), (wg4, "no")]: + FellowNominationVote.objects.get_or_create( + nomination=self.under_review_tie, + voter=voter, + defaults={"vote": vote}, + ) + + # All abstains + for voter in self.wg_members: + FellowNominationVote.objects.get_or_create( + nomination=self.under_review_abstains, + voter=voter, + defaults={"vote": "abstain"}, + ) + + # One vote cast + FellowNominationVote.objects.get_or_create( + nomination=self.under_review_one_vote, + voter=wg1, + defaults={"vote": "yes", "comment": "Looks promising."}, + ) + + self.stdout.write(f" Created {FellowNominationVote.objects.count()} votes") + + def _create_fellows(self): + self.stdout.write("Creating fellow records for roster...") + + fellow_data = [ + ("guido_van_rossum", "Guido", "van Rossum", "guido@python.org", 2001), + ("carol_willing", "Carol", "Willing", "carol@python.org", 2017), + ("mariatta_wijaya", "Mariatta", "Wijaya", "mariatta@python.org", 2018), + ("naomi_ceder", "Naomi", "Ceder", "naomi@python.org", 2015), + ("victor_stinner", "Victor", "Stinner", "victor@python.org", 2016), + ("brett_cannon", "Brett", "Cannon", "brett@python.org", 2010), + ("barry_warsaw", "Barry", "Warsaw", "barry@python.org", 2009), + ] + for username, first, last, email, year in fellow_data: + user = self._get_or_create_user(username, first, last, email) + self._get_or_create_fellow(f"{first} {last}", year, user=user) + + # Emeritus and deceased examples for roster section testing + self._get_or_create_fellow("Emeritus Example", 2005, status="emeritus", emeritus_year=2020) + self._get_or_create_fellow("In Memoriam Example", 2003, status="deceased", notes="Remembered fondly.") diff --git a/nominations/managers.py b/nominations/managers.py new file mode 100644 index 000000000..cd5ba2135 --- /dev/null +++ b/nominations/managers.py @@ -0,0 +1,22 @@ +from django.db import models +from django.db.models import Q +from django.utils import timezone + + +class FellowNominationQuerySet(models.QuerySet): + def active(self): + """Exclude accepted/not_accepted, keep nominations whose expiry round + is still in the future OR whose expiry_round has not been set yet.""" + return self.exclude(status__in=["accepted", "not_accepted"]).filter( + Q(expiry_round__quarter_end__gte=timezone.now().date()) | Q(expiry_round__isnull=True) + ) + + def for_round(self, round_obj): + """Filter by nomination_round.""" + return self.filter(nomination_round=round_obj) + + def pending(self): + return self.filter(status="pending") + + def accepted(self): + return self.filter(status="accepted") diff --git a/nominations/migrations/0003_fellow_nominations.py b/nominations/migrations/0003_fellow_nominations.py new file mode 100644 index 000000000..b0b4ecaab --- /dev/null +++ b/nominations/migrations/0003_fellow_nominations.py @@ -0,0 +1,183 @@ +# Generated by Django 4.2.28 on 2026-02-06 01:17 + +import django.db.models.deletion +import django.utils.timezone +import markupfield.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("nominations", "0002_auto_20190514_1435"), + ] + + operations = [ + migrations.CreateModel( + name="FellowNominationRound", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("year", models.PositiveIntegerField()), + ( + "quarter", + models.PositiveSmallIntegerField( + choices=[(1, "Q1 (Jan-Mar)"), (2, "Q2 (Apr-Jun)"), (3, "Q3 (Jul-Sep)"), (4, "Q4 (Oct-Dec)")] + ), + ), + ("quarter_start", models.DateField(help_text="First day of the quarter.")), + ("quarter_end", models.DateField(help_text="Last day of the quarter.")), + ( + "nominations_cutoff", + models.DateField(help_text="20th of month 2 per WG Charter (Feb 20, May 20, Aug 20, Nov 20)."), + ), + ("review_start", models.DateField(help_text="Same as nominations cutoff.")), + ("review_end", models.DateField(help_text="20th of month 3 (Mar 20, Jun 20, Sep 20, Dec 20).")), + ("is_open", models.BooleanField(default=True, help_text="Whether accepting nominations.")), + ("slug", models.SlugField(blank=True, unique=True)), + ], + options={ + "ordering": ["-year", "-quarter"], + "unique_together": {("year", "quarter")}, + }, + ), + migrations.CreateModel( + name="FellowNomination", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created", models.DateTimeField(blank=True, db_index=True, default=django.utils.timezone.now)), + ("updated", models.DateTimeField(blank=True, default=django.utils.timezone.now)), + ("nominee_name", models.CharField(max_length=255)), + ("nominee_email", models.EmailField(max_length=255)), + ("nomination_statement", markupfield.fields.MarkupField(rendered_field=True, escape_html=True)), + ( + "nomination_statement_markup_type", + models.CharField( + choices=[ + ("", "--"), + ("html", "HTML"), + ("plain", "Plain"), + ("markdown", "Markdown"), + ("restructuredtext", "Restructured Text"), + ], + default="markdown", + editable=False, + max_length=30, + ), + ), + ("_nomination_statement_rendered", models.TextField(editable=False)), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("under_review", "Under Review"), + ("accepted", "Accepted"), + ("not_accepted", "Not Accepted"), + ], + db_index=True, + default="pending", + max_length=20, + ), + ), + ( + "nominee_is_fellow_at_submission", + models.BooleanField( + default=False, help_text="Snapshot: was the nominee already a Fellow at submission time?" + ), + ), + ( + "creator", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_creator", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "expiry_round", + models.ForeignKey( + blank=True, + help_text="Round 4 quarters after submission; nomination expires after this round.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="expiring_nominations", + to="nominations.fellownominationround", + ), + ), + ( + "last_modified_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="%(app_label)s_%(class)s_modified", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "nomination_round", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="nominations", + to="nominations.fellownominationround", + ), + ), + ( + "nominator", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="fellow_nominations_made", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "nominee_user", + models.ForeignKey( + blank=True, + help_text="Linked if nominee has a python.org account.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="fellow_nominations_received", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["-created"], + }, + ), + migrations.CreateModel( + name="FellowNominationVote", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "vote", + models.CharField(choices=[("yes", "Yes"), ("no", "No"), ("abstain", "Abstain")], max_length=10), + ), + ("comment", models.TextField(blank=True)), + ("voted_at", models.DateTimeField(auto_now_add=True)), + ( + "nomination", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="votes", + to="nominations.fellownomination", + ), + ), + ( + "voter", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="fellow_nomination_votes", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "unique_together": {("nomination", "voter")}, + }, + ), + ] diff --git a/nominations/migrations/0004_fellow.py b/nominations/migrations/0004_fellow.py new file mode 100644 index 000000000..a2712b0f6 --- /dev/null +++ b/nominations/migrations/0004_fellow.py @@ -0,0 +1,47 @@ +# Generated by Django 4.2.28 on 2026-02-06 03:34 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("nominations", "0003_fellow_nominations"), + ] + + operations = [ + migrations.CreateModel( + name="Fellow", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("year_elected", models.PositiveIntegerField()), + ( + "status", + models.CharField( + choices=[("active", "Active"), ("emeritus", "Emeritus"), ("deceased", "Deceased")], + db_index=True, + default="active", + max_length=10, + ), + ), + ("emeritus_year", models.PositiveIntegerField(blank=True, null=True)), + ("notes", models.TextField(blank=True)), + ( + "user", + models.OneToOneField( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="fellow", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["name"], + }, + ), + ] diff --git a/nominations/models.py b/nominations/models.py index f52a286be..63e9d5974 100644 --- a/nominations/models.py +++ b/nominations/models.py @@ -1,16 +1,21 @@ import datetime +from django.conf import settings from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.urls import reverse +from django.utils import timezone from django.utils.text import slugify from fastly.utils import purge_url from markupfield.fields import MarkupField +from cms.models import ContentManageable from users.models import User +from nominations.managers import FellowNominationQuerySet + class Election(models.Model): class Meta: @@ -271,3 +276,227 @@ def purge_nomination_pages(sender, instance, created, **kwargs): "nominations:nominees_list", kwargs={"election": instance.election.slug} ) ) + + +class Fellow(models.Model): + """A PSF Fellow — reference data managed via Django admin.""" + + ACTIVE = "active" + EMERITUS = "emeritus" + DECEASED = "deceased" + STATUS_CHOICES = ( + (ACTIVE, "Active"), + (EMERITUS, "Emeritus"), + (DECEASED, "Deceased"), + ) + + name = models.CharField(max_length=255) + year_elected = models.PositiveIntegerField() + status = models.CharField( + max_length=10, + choices=STATUS_CHOICES, + default=ACTIVE, + db_index=True, + ) + emeritus_year = models.PositiveIntegerField(null=True, blank=True) + notes = models.TextField(blank=True) + user = models.OneToOneField( + User, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="fellow", + ) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name + + +class FellowNominationRound(models.Model): + """Quarterly round for PSF Fellow Work Group consideration.""" + + class Quarter(models.IntegerChoices): + Q1 = 1, "Q1 (Jan-Mar)" + Q2 = 2, "Q2 (Apr-Jun)" + Q3 = 3, "Q3 (Jul-Sep)" + Q4 = 4, "Q4 (Oct-Dec)" + + year = models.PositiveIntegerField() + quarter = models.PositiveSmallIntegerField(choices=Quarter.choices) + quarter_start = models.DateField(help_text="First day of the quarter.") + quarter_end = models.DateField(help_text="Last day of the quarter.") + nominations_cutoff = models.DateField( + help_text="20th of month 2 per WG Charter (Feb 20, May 20, Aug 20, Nov 20)." + ) + review_start = models.DateField(help_text="Same as nominations cutoff.") + review_end = models.DateField( + help_text="20th of month 3 (Mar 20, Jun 20, Sep 20, Dec 20)." + ) + is_open = models.BooleanField(default=True, help_text="Whether accepting nominations.") + slug = models.SlugField(unique=True, blank=True) + + class Meta: + unique_together = ("year", "quarter") + ordering = ["-year", "-quarter"] + + def __str__(self): + return f"{self.year} Q{self.quarter}" + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = f"{self.year}-q{self.quarter}" + super().save(*args, **kwargs) + + @property + def is_current(self): + today = timezone.now().date() + return self.quarter_start <= today <= self.quarter_end + + @property + def is_accepting_nominations(self): + today = timezone.now().date() + return self.is_open and today <= self.nominations_cutoff + + @property + def is_in_review(self): + today = timezone.now().date() + return self.review_start <= today <= self.review_end + + +class FellowNomination(ContentManageable): + """A nomination for the PSF Fellow membership.""" + + PENDING = "pending" + UNDER_REVIEW = "under_review" + ACCEPTED = "accepted" + NOT_ACCEPTED = "not_accepted" + STATUS_CHOICES = ( + (PENDING, "Pending"), + (UNDER_REVIEW, "Under Review"), + (ACCEPTED, "Accepted"), + (NOT_ACCEPTED, "Not Accepted"), + ) + + nominee_name = models.CharField(max_length=255) + nominee_email = models.EmailField(max_length=255) + nomination_statement = MarkupField( + escape_html=True, markup_type="markdown" + ) + nominator = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="fellow_nominations_made", + on_delete=models.CASCADE, + ) + nomination_round = models.ForeignKey( + FellowNominationRound, + related_name="nominations", + on_delete=models.PROTECT, + ) + status = models.CharField( + max_length=20, + choices=STATUS_CHOICES, + default=PENDING, + db_index=True, + ) + expiry_round = models.ForeignKey( + FellowNominationRound, + related_name="expiring_nominations", + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Round 4 quarters after submission; nomination expires after this round.", + ) + nominee_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="fellow_nominations_received", + on_delete=models.SET_NULL, + null=True, + blank=True, + help_text="Linked if nominee has a python.org account.", + ) + nominee_is_fellow_at_submission = models.BooleanField( + default=False, + help_text="Snapshot: was the nominee already a Fellow at submission time?", + ) + + objects = FellowNominationQuerySet.as_manager() + + class Meta: + ordering = ["-created"] + + def __str__(self): + return f"Fellow Nomination: {self.nominee_name} (by {self.nominator})" + + def get_absolute_url(self): + return reverse("nominations:fellow_nomination_detail", kwargs={"pk": self.pk}) + + @property + def is_active(self): + if self.status in (self.ACCEPTED, self.NOT_ACCEPTED): + return False + if self.expiry_round and self.expiry_round.quarter_end < timezone.now().date(): + return False + return True + + @property + def nominee_is_already_fellow(self): + if self.nominee_user: + try: + return self.nominee_user.fellow is not None + except Fellow.DoesNotExist: + return False + return False + + @property + def vote_result(self): + """Per WG Charter: 50%+1 of votes cast (excluding abstentions).""" + votes = self.votes.exclude(vote="abstain") + total = votes.count() + if total == 0: + return None + yes_count = votes.filter(vote="yes").count() + return yes_count > total / 2 + + +class FellowNominationVote(models.Model): + """WG member vote on a Fellow nomination.""" + + YES = "yes" + NO = "no" + ABSTAIN = "abstain" + VOTE_CHOICES = ( + (YES, "Yes"), + (NO, "No"), + (ABSTAIN, "Abstain"), + ) + + nomination = models.ForeignKey( + FellowNomination, + related_name="votes", + on_delete=models.CASCADE, + ) + voter = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="fellow_nomination_votes", + on_delete=models.CASCADE, + ) + vote = models.CharField(max_length=10, choices=VOTE_CHOICES) + comment = models.TextField(blank=True) + voted_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ("nomination", "voter") + + def __str__(self): + return f"{self.voter} voted {self.vote} on {self.nomination}" + + +@receiver(post_save, sender=FellowNomination) +def purge_fellow_nomination_pages(sender, instance, created, **kwargs): + """Purge Fastly CDN cache for Fellow nomination pages.""" + if kwargs.get("raw", False): + return + purge_url(instance.get_absolute_url()) diff --git a/nominations/notifications.py b/nominations/notifications.py new file mode 100644 index 000000000..5fb3abea1 --- /dev/null +++ b/nominations/notifications.py @@ -0,0 +1,106 @@ +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.mail import EmailMultiAlternatives +from django.template import TemplateDoesNotExist +from django.template.loader import render_to_string + + +def _get_site_url(request=None): + """Build the site base URL from the request or the Sites framework.""" + if request: + scheme = "https" if request.is_secure() else "http" + return f"{scheme}://{request.get_host()}" + try: + site = Site.objects.get_current() + domain = site.domain + scheme = "http" if "localhost" in domain or "127.0.0.1" in domain else "https" + return f"{scheme}://{domain}" + except Exception: + return "https://www.python.org" + + +class BaseFellowNominationNotification: + subject_template = None + message_template = None + message_html_template = None + email_context_keys = None + + def get_subject(self, context): + return render_to_string(self.subject_template, context).strip() + + def get_message(self, context): + return render_to_string(self.message_template, context).strip() + + def get_html_message(self, context): + """Render the HTML template if it exists, returning None otherwise.""" + if not self.message_html_template: + return None + try: + return render_to_string(self.message_html_template, context).strip() + except TemplateDoesNotExist: + return None + + def get_recipient_list(self, context): + raise NotImplementedError + + def get_email_context(self, **kwargs): + context = {k: kwargs.get(k) for k in self.email_context_keys} + context["site_url"] = _get_site_url(kwargs.get("request")) + return context + + def notify(self, **kwargs): + context = self.get_email_context(**kwargs) + email = EmailMultiAlternatives( + subject=self.get_subject(context), + body=self.get_message(context), + to=self.get_recipient_list(context), + from_email=settings.DEFAULT_FROM_EMAIL, + ) + html_body = self.get_html_message(context) + if html_body: + email.attach_alternative(html_body, "text/html") + email.send() + + +class FellowNominationSubmittedToNominator(BaseFellowNominationNotification): + subject_template = "nominations/email/fellow_nomination_submitted_subject.txt" + message_template = "nominations/email/fellow_nomination_submitted.txt" + message_html_template = "nominations/email/fellow_nomination_submitted.html" + email_context_keys = ["nomination", "request"] + + def get_recipient_list(self, context): + return [context["nomination"].nominator.email] + + +class FellowNominationSubmittedToWG(BaseFellowNominationNotification): + subject_template = "nominations/email/fellow_nomination_submitted_wg_subject.txt" + message_template = "nominations/email/fellow_nomination_submitted_wg.txt" + message_html_template = "nominations/email/fellow_nomination_submitted_wg.html" + email_context_keys = ["nomination", "request"] + + def get_recipient_list(self, context): + return [settings.FELLOW_WG_NOTIFICATION_EMAIL] + + +class FellowNominationAcceptedNotification(BaseFellowNominationNotification): + """Notify nominator when their Fellow nomination is accepted.""" + + subject_template = "nominations/email/fellow_nomination_accepted_subject.txt" + message_template = "nominations/email/fellow_nomination_accepted.txt" + message_html_template = "nominations/email/fellow_nomination_accepted.html" + email_context_keys = ["nomination", "request"] + + def get_recipient_list(self, context): + return [context["nomination"].nominator.email] + + +class FellowNominationNotAcceptedNotification(BaseFellowNominationNotification): + """Notify nominator when their Fellow nomination is not accepted.""" + + subject_template = "nominations/email/fellow_nomination_not_accepted_subject.txt" + message_template = "nominations/email/fellow_nomination_not_accepted.txt" + message_html_template = "nominations/email/fellow_nomination_not_accepted.html" + email_context_keys = ["nomination", "request"] + + def get_recipient_list(self, context): + return [context["nomination"].nominator.email] diff --git a/nominations/tasks.py b/nominations/tasks.py new file mode 100644 index 000000000..dc2b3aaf6 --- /dev/null +++ b/nominations/tasks.py @@ -0,0 +1,16 @@ +import logging + +from celery import shared_task +from django.core.management import call_command + +logger = logging.getLogger(__name__) + + +@shared_task +def close_expired_fellow_nominations(): + """Close Fellow nominations that have passed their expiry round.""" + try: + call_command("close_expired_fellow_nominations") + except Exception: + logger.exception("Failed to close expired Fellow nominations") + raise diff --git a/nominations/tests/__init__.py b/nominations/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/nominations/tests/factories.py b/nominations/tests/factories.py new file mode 100644 index 000000000..e71c4c8d6 --- /dev/null +++ b/nominations/tests/factories.py @@ -0,0 +1,44 @@ +import datetime + +import factory +from factory.django import DjangoModelFactory + +from nominations.models import FellowNomination, FellowNominationRound +from users.models import User + + +class UserFactory(DjangoModelFactory): + class Meta: + model = User + + username = factory.Sequence(lambda n: f"testuser{n}") + email = factory.LazyAttribute(lambda o: f"{o.username}@example.com") + first_name = factory.Faker("first_name") + last_name = factory.Faker("last_name") + password = factory.PostGenerationMethodCall("set_password", "testpass123") + + +class FellowNominationRoundFactory(DjangoModelFactory): + class Meta: + model = FellowNominationRound + + year = 2026 + quarter = 1 + quarter_start = datetime.date(2026, 1, 1) + quarter_end = datetime.date(2026, 3, 31) + nominations_cutoff = datetime.date(2026, 2, 20) + review_start = datetime.date(2026, 2, 20) + review_end = datetime.date(2026, 3, 20) + is_open = True + + +class FellowNominationFactory(DjangoModelFactory): + class Meta: + model = FellowNomination + + nominee_name = factory.Faker("name") + nominee_email = factory.Faker("email") + nomination_statement = "This person has made great contributions to Python." + nominator = factory.SubFactory(UserFactory) + nomination_round = factory.SubFactory(FellowNominationRoundFactory) + status = "pending" diff --git a/nominations/tests/test_forms.py b/nominations/tests/test_forms.py new file mode 100644 index 000000000..223485da8 --- /dev/null +++ b/nominations/tests/test_forms.py @@ -0,0 +1,62 @@ +from django.test import RequestFactory, TestCase + +from nominations.forms import FellowNominationForm +from nominations.tests.factories import FellowNominationRoundFactory, UserFactory + + +class FellowNominationFormTests(TestCase): + def setUp(self): + self.round = FellowNominationRoundFactory() + self.user = UserFactory(email="nominator@example.com") + self.factory = RequestFactory() + self.request = self.factory.get("/") + self.request.user = self.user + + def test_valid_form(self): + data = { + "nominee_name": "Jane Doe", + "nominee_email": "jane@example.com", + "nomination_statement": "Jane has made outstanding contributions to the Python community through years of dedicated work on documentation, mentoring, and conference organization.", + "nomination_statement_markup_type": "markdown", + } + form = FellowNominationForm(data=data, request=self.request) + self.assertTrue(form.is_valid()) + + def test_required_fields(self): + form = FellowNominationForm(data={}, request=self.request) + self.assertFalse(form.is_valid()) + self.assertIn("nominee_name", form.errors) + self.assertIn("nominee_email", form.errors) + + def test_self_nomination_prevented(self): + data = { + "nominee_name": "Self Nominator", + "nominee_email": "nominator@example.com", + "nomination_statement": "I am great.", + "nomination_statement_markup_type": "markdown", + } + form = FellowNominationForm(data=data, request=self.request) + self.assertFalse(form.is_valid()) + self.assertIn("nominee_email", form.errors) + + def test_self_nomination_case_insensitive(self): + data = { + "nominee_name": "Self Nominator", + "nominee_email": "Nominator@Example.com", + "nomination_statement": "I am great.", + "nomination_statement_markup_type": "markdown", + } + form = FellowNominationForm(data=data, request=self.request) + self.assertFalse(form.is_valid()) + self.assertIn("nominee_email", form.errors) + + def test_invalid_email(self): + data = { + "nominee_name": "Jane Doe", + "nominee_email": "not-an-email", + "nomination_statement": "Great contributor.", + "nomination_statement_markup_type": "markdown", + } + form = FellowNominationForm(data=data, request=self.request) + self.assertFalse(form.is_valid()) + self.assertIn("nominee_email", form.errors) diff --git a/nominations/tests/test_management_views.py b/nominations/tests/test_management_views.py new file mode 100644 index 000000000..b68089253 --- /dev/null +++ b/nominations/tests/test_management_views.py @@ -0,0 +1,393 @@ +import datetime + +from django.contrib.auth.models import Group +from django.test import Client, TestCase +from django.urls import reverse + +from nominations.models import FellowNomination, FellowNominationRound +from nominations.tests.factories import ( + FellowNominationFactory, + FellowNominationRoundFactory, + UserFactory, +) + + +class FellowNominationDashboardViewTests(TestCase): + def setUp(self): + self.client = Client() + self.wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + self.wg_user = UserFactory() + self.wg_user.groups.add(self.wg_group) + self.regular_user = UserFactory() + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + self.url = reverse("nominations:fellow_nomination_dashboard") + self.client.login(username=self.wg_user.username, password="testpass123") + + def test_wg_member_can_access(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_non_wg_user_gets_403(self): + self.client.login(username=self.regular_user.username, password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_staff_can_access(self): + staff_user = UserFactory(is_staff=True) + self.client.login(username=staff_user.username, password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_dashboard_shows_current_round_stats(self): + FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.PENDING, + ) + FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.UNDER_REVIEW, + ) + FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.ACCEPTED, + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["current_round"], self.round) + self.assertEqual(response.context["total_nominations"], 3) + self.assertEqual(response.context["pending_count"], 1) + self.assertEqual(response.context["under_review_count"], 1) + self.assertEqual(response.context["accepted_count"], 1) + self.assertEqual(response.context["not_accepted_count"], 0) + + +class FellowNominationRoundListViewTests(TestCase): + def setUp(self): + self.client = Client() + self.wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + self.wg_user = UserFactory() + self.wg_user.groups.add(self.wg_group) + self.regular_user = UserFactory() + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + self.url = reverse("nominations:fellow_round_list") + self.client.login(username=self.wg_user.username, password="testpass123") + + def test_wg_member_can_access(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_non_wg_user_gets_403(self): + self.client.login(username=self.regular_user.username, password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_lists_rounds(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + rounds = list(response.context["rounds"]) + self.assertIn(self.round, rounds) + + +class FellowNominationRoundCreateViewTests(TestCase): + def setUp(self): + self.client = Client() + self.wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + self.wg_user = UserFactory() + self.wg_user.groups.add(self.wg_group) + self.regular_user = UserFactory() + self.url = reverse("nominations:fellow_round_create") + self.client.login(username=self.wg_user.username, password="testpass123") + + def test_wg_member_can_create_round(self): + data = { + "year": 2026, + "quarter": 2, + "quarter_start": "", + "quarter_end": "", + "nominations_cutoff": "", + "review_start": "", + "review_end": "", + "is_open": True, + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertTrue(FellowNominationRound.objects.filter(year=2026, quarter=2).exists()) + created_round = FellowNominationRound.objects.get(year=2026, quarter=2) + # Verify auto-populated dates from the form's clean method + self.assertEqual(created_round.quarter_start, datetime.date(2026, 4, 1)) + self.assertEqual(created_round.quarter_end, datetime.date(2026, 6, 30)) + self.assertEqual(created_round.nominations_cutoff, datetime.date(2026, 5, 20)) + self.assertEqual(created_round.review_start, datetime.date(2026, 5, 20)) + self.assertEqual(created_round.review_end, datetime.date(2026, 6, 20)) + + def test_non_wg_user_gets_403(self): + self.client.login(username=self.regular_user.username, password="testpass123") + response = self.client.post( + self.url, + { + "year": 2026, + "quarter": 2, + "quarter_start": "", + "quarter_end": "", + "nominations_cutoff": "", + "review_start": "", + "review_end": "", + "is_open": True, + }, + ) + self.assertEqual(response.status_code, 403) + + def test_duplicate_quarter_prevented(self): + # Create the first round + FellowNominationRoundFactory( + year=2026, + quarter=3, + quarter_start=datetime.date(2026, 7, 1), + quarter_end=datetime.date(2026, 9, 30), + nominations_cutoff=datetime.date(2026, 8, 20), + review_start=datetime.date(2026, 8, 20), + review_end=datetime.date(2026, 9, 20), + ) + # Attempt to create a duplicate + data = { + "year": 2026, + "quarter": 3, + "quarter_start": "", + "quarter_end": "", + "nominations_cutoff": "", + "review_start": "", + "review_end": "", + "is_open": True, + } + response = self.client.post(self.url, data) + # Should re-render the form with validation errors (200, not 302) + self.assertEqual(response.status_code, 200) + # Only one round for 2026 Q3 should exist + self.assertEqual(FellowNominationRound.objects.filter(year=2026, quarter=3).count(), 1) + + +class FellowNominationRoundUpdateViewTests(TestCase): + def setUp(self): + self.client = Client() + self.wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + self.wg_user = UserFactory() + self.wg_user.groups.add(self.wg_group) + self.regular_user = UserFactory() + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + self.url = reverse( + "nominations:fellow_round_update", + kwargs={"slug": self.round.slug}, + ) + self.client.login(username=self.wg_user.username, password="testpass123") + + def test_wg_member_can_update_round(self): + data = { + "year": 2026, + "quarter": 1, + "quarter_start": "2026-01-01", + "quarter_end": "2026-03-31", + "nominations_cutoff": "2026-02-25", + "review_start": "2026-02-25", + "review_end": "2026-03-25", + "is_open": True, + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.round.refresh_from_db() + self.assertEqual(self.round.nominations_cutoff, datetime.date(2026, 2, 25)) + self.assertEqual(self.round.review_end, datetime.date(2026, 3, 25)) + + def test_non_wg_user_gets_403(self): + self.client.login(username=self.regular_user.username, password="testpass123") + response = self.client.post( + self.url, + { + "year": 2026, + "quarter": 1, + "quarter_start": "2026-01-01", + "quarter_end": "2026-03-31", + "nominations_cutoff": "2026-02-25", + "review_start": "2026-02-25", + "review_end": "2026-03-25", + "is_open": True, + }, + ) + self.assertEqual(response.status_code, 403) + + +class FellowNominationRoundToggleViewTests(TestCase): + def setUp(self): + self.client = Client() + self.wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + self.wg_user = UserFactory() + self.wg_user.groups.add(self.wg_group) + self.regular_user = UserFactory() + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + is_open=True, + ) + self.url = reverse( + "nominations:fellow_round_toggle", + kwargs={"slug": self.round.slug}, + ) + self.client.login(username=self.wg_user.username, password="testpass123") + + def test_toggle_closes_open_round(self): + self.assertTrue(self.round.is_open) + response = self.client.post(self.url) + self.assertEqual(response.status_code, 302) + self.round.refresh_from_db() + self.assertFalse(self.round.is_open) + + def test_toggle_opens_closed_round(self): + self.round.is_open = False + self.round.save() + response = self.client.post(self.url) + self.assertEqual(response.status_code, 302) + self.round.refresh_from_db() + self.assertTrue(self.round.is_open) + + def test_get_returns_405(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 405) + + +class FellowNominationEditViewTests(TestCase): + def setUp(self): + self.client = Client() + self.wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + self.wg_user = UserFactory() + self.wg_user.groups.add(self.wg_group) + self.regular_user = UserFactory() + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + self.nomination = FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.PENDING, + ) + self.url = reverse( + "nominations:fellow_nomination_edit", + kwargs={"pk": self.nomination.pk}, + ) + self.client.login(username=self.wg_user.username, password="testpass123") + + def test_wg_member_can_edit(self): + data = { + "nominee_name": "Updated Name", + "nominee_email": "updated@example.com", + "nomination_statement": "Updated statement.", + "nomination_statement_markup_type": "markdown", + "status": FellowNomination.UNDER_REVIEW, + "nominee_user": "", + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.nomination.refresh_from_db() + self.assertEqual(self.nomination.nominee_name, "Updated Name") + self.assertEqual(self.nomination.nominee_email, "updated@example.com") + + def test_last_modified_by_set(self): + data = { + "nominee_name": "Modified Name", + "nominee_email": "modified@example.com", + "nomination_statement": "Modified statement.", + "nomination_statement_markup_type": "markdown", + "status": FellowNomination.PENDING, + "nominee_user": "", + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.nomination.refresh_from_db() + self.assertEqual(self.nomination.last_modified_by, self.wg_user) + + def test_non_wg_user_gets_403(self): + self.client.login(username=self.regular_user.username, password="testpass123") + data = { + "nominee_name": "Hacker Name", + "nominee_email": "hacker@example.com", + "nomination_statement": "Should not work.", + "nomination_statement_markup_type": "markdown", + "status": FellowNomination.PENDING, + "nominee_user": "", + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 403) + + +class FellowNominationDeleteViewTests(TestCase): + def setUp(self): + self.client = Client() + self.wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + self.wg_user = UserFactory() + self.wg_user.groups.add(self.wg_group) + self.regular_user = UserFactory() + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + self.nomination = FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.PENDING, + ) + self.url = reverse( + "nominations:fellow_nomination_delete", + kwargs={"pk": self.nomination.pk}, + ) + self.client.login(username=self.wg_user.username, password="testpass123") + + def test_wg_member_can_delete(self): + pk = self.nomination.pk + response = self.client.post(self.url) + self.assertEqual(response.status_code, 302) + self.assertFalse(FellowNomination.objects.filter(pk=pk).exists()) + + def test_non_wg_user_gets_403(self): + self.client.login(username=self.regular_user.username, password="testpass123") + response = self.client.post(self.url) + self.assertEqual(response.status_code, 403) + + def test_get_shows_confirmation(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) diff --git a/nominations/tests/test_models.py b/nominations/tests/test_models.py new file mode 100644 index 000000000..e03ec0ef6 --- /dev/null +++ b/nominations/tests/test_models.py @@ -0,0 +1,315 @@ +import datetime +from unittest.mock import patch + +from django.db import IntegrityError +from django.test import TestCase +from django.utils import timezone + +from nominations.models import ( + Fellow, + FellowNomination, + FellowNominationVote, +) +from nominations.tests.factories import ( + FellowNominationFactory, + FellowNominationRoundFactory, + UserFactory, +) + + +class FellowNominationRoundTests(TestCase): + def setUp(self): + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + + def test_str(self): + self.assertEqual(str(self.round), "2026 Q1") + + def test_slug_auto_generated(self): + self.assertEqual(self.round.slug, "2026-q1") + + @patch("nominations.models.timezone.now") + def test_is_current_true(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 2, 15, 12, 0)) + self.assertTrue(self.round.is_current) + + @patch("nominations.models.timezone.now") + def test_is_current_false(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 5, 1, 12, 0)) + self.assertFalse(self.round.is_current) + + @patch("nominations.models.timezone.now") + def test_is_accepting_nominations_true(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 1, 15, 12, 0)) + self.assertTrue(self.round.is_accepting_nominations) + + @patch("nominations.models.timezone.now") + def test_is_accepting_nominations_false_after_cutoff(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 2, 21, 12, 0)) + self.assertFalse(self.round.is_accepting_nominations) + + @patch("nominations.models.timezone.now") + def test_is_accepting_nominations_false_when_closed(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 1, 15, 12, 0)) + self.round.is_open = False + self.round.save() + self.assertFalse(self.round.is_accepting_nominations) + + @patch("nominations.models.timezone.now") + def test_is_in_review_true(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 3, 1, 12, 0)) + self.assertTrue(self.round.is_in_review) + + @patch("nominations.models.timezone.now") + def test_is_in_review_false(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 1, 15, 12, 0)) + self.assertFalse(self.round.is_in_review) + + def test_unique_together(self): + with self.assertRaises(IntegrityError): + FellowNominationRoundFactory(year=2026, quarter=1) + + +class FellowNominationTests(TestCase): + def setUp(self): + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + self.expiry_round = FellowNominationRoundFactory( + year=2026, + quarter=4, + quarter_start=datetime.date(2026, 10, 1), + quarter_end=datetime.date(2026, 12, 31), + nominations_cutoff=datetime.date(2026, 11, 20), + review_start=datetime.date(2026, 11, 20), + review_end=datetime.date(2026, 12, 20), + ) + self.user = UserFactory() + self.nomination = FellowNominationFactory( + nominator=self.user, + nomination_round=self.round, + expiry_round=self.expiry_round, + ) + + def test_str(self): + self.assertIn("Fellow Nomination:", str(self.nomination)) + self.assertIn(self.nomination.nominee_name, str(self.nomination)) + + def test_get_absolute_url(self): + url = self.nomination.get_absolute_url() + self.assertEqual(url, f"/nominations/fellows/nomination/{self.nomination.pk}/") + + @patch("nominations.models.timezone.now") + def test_is_active_pending(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 2, 1, 12, 0)) + self.assertTrue(self.nomination.is_active) + + def test_is_active_false_when_accepted(self): + self.nomination.status = "accepted" + self.nomination.save() + self.assertFalse(self.nomination.is_active) + + def test_is_active_false_when_not_accepted(self): + self.nomination.status = "not_accepted" + self.nomination.save() + self.assertFalse(self.nomination.is_active) + + @patch("nominations.models.timezone.now") + def test_is_active_false_when_expired(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2027, 2, 1, 12, 0)) + self.assertFalse(self.nomination.is_active) + + def test_nominee_is_already_fellow_false_no_user(self): + self.nomination.nominee_user = None + self.assertFalse(self.nomination.nominee_is_already_fellow) + + def test_nominee_is_already_fellow_false_no_fellow_record(self): + nominee_user = UserFactory() + self.nomination.nominee_user = nominee_user + self.assertFalse(self.nomination.nominee_is_already_fellow) + + def test_nominee_is_already_fellow_true(self): + nominee_user = UserFactory() + Fellow.objects.create( + name="Test Fellow", + year_elected=2020, + user=nominee_user, + ) + self.nomination.nominee_user = nominee_user + self.assertTrue(self.nomination.nominee_is_already_fellow) + + +class FellowNominationVoteResultTests(TestCase): + def setUp(self): + self.round = FellowNominationRoundFactory() + self.nomination = FellowNominationFactory(nomination_round=self.round) + + def test_vote_result_none_when_no_votes(self): + self.assertIsNone(self.nomination.vote_result) + + def test_vote_result_true_majority_yes(self): + for _ in range(3): + FellowNominationVote.objects.create( + nomination=self.nomination, + voter=UserFactory(), + vote="yes", + ) + FellowNominationVote.objects.create( + nomination=self.nomination, + voter=UserFactory(), + vote="no", + ) + self.assertTrue(self.nomination.vote_result) + + def test_vote_result_false_majority_no(self): + FellowNominationVote.objects.create( + nomination=self.nomination, + voter=UserFactory(), + vote="yes", + ) + for _ in range(3): + FellowNominationVote.objects.create( + nomination=self.nomination, + voter=UserFactory(), + vote="no", + ) + self.assertFalse(self.nomination.vote_result) + + def test_vote_result_abstentions_excluded(self): + FellowNominationVote.objects.create( + nomination=self.nomination, + voter=UserFactory(), + vote="yes", + ) + FellowNominationVote.objects.create( + nomination=self.nomination, + voter=UserFactory(), + vote="abstain", + ) + # 1 yes out of 1 non-abstain = passes + self.assertTrue(self.nomination.vote_result) + + def test_vote_result_tie_fails(self): + FellowNominationVote.objects.create( + nomination=self.nomination, + voter=UserFactory(), + vote="yes", + ) + FellowNominationVote.objects.create( + nomination=self.nomination, + voter=UserFactory(), + vote="no", + ) + # 1 yes out of 2 = 50%, need >50% to pass + self.assertFalse(self.nomination.vote_result) + + def test_unique_together_prevents_duplicate_vote(self): + voter = UserFactory() + FellowNominationVote.objects.create( + nomination=self.nomination, + voter=voter, + vote="yes", + ) + with self.assertRaises(IntegrityError): + FellowNominationVote.objects.create( + nomination=self.nomination, + voter=voter, + vote="no", + ) + + +class FellowNominationQuerySetTests(TestCase): + def setUp(self): + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + self.future_round = FellowNominationRoundFactory( + year=2026, + quarter=4, + quarter_start=datetime.date(2026, 10, 1), + quarter_end=datetime.date(2026, 12, 31), + nominations_cutoff=datetime.date(2026, 11, 20), + review_start=datetime.date(2026, 11, 20), + review_end=datetime.date(2026, 12, 20), + ) + + @patch("nominations.managers.timezone.now") + def test_active_excludes_accepted(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 2, 1, 12, 0)) + FellowNominationFactory( + nomination_round=self.round, + expiry_round=self.future_round, + status="accepted", + ) + self.assertEqual(FellowNomination.objects.active().count(), 0) + + @patch("nominations.managers.timezone.now") + def test_active_includes_pending(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 2, 1, 12, 0)) + FellowNominationFactory( + nomination_round=self.round, + expiry_round=self.future_round, + status="pending", + ) + self.assertEqual(FellowNomination.objects.active().count(), 1) + + def test_for_round(self): + FellowNominationFactory( + nomination_round=self.round, + expiry_round=self.future_round, + ) + round2 = FellowNominationRoundFactory( + year=2026, + quarter=2, + quarter_start=datetime.date(2026, 4, 1), + quarter_end=datetime.date(2026, 6, 30), + nominations_cutoff=datetime.date(2026, 5, 20), + review_start=datetime.date(2026, 5, 20), + review_end=datetime.date(2026, 6, 20), + ) + FellowNominationFactory( + nomination_round=round2, + expiry_round=self.future_round, + ) + self.assertEqual(FellowNomination.objects.for_round(self.round).count(), 1) + + def test_pending(self): + FellowNominationFactory( + nomination_round=self.round, + status="pending", + ) + FellowNominationFactory( + nomination_round=self.round, + status="under_review", + ) + self.assertEqual(FellowNomination.objects.pending().count(), 1) + + def test_accepted(self): + FellowNominationFactory( + nomination_round=self.round, + status="accepted", + ) + FellowNominationFactory( + nomination_round=self.round, + status="pending", + ) + self.assertEqual(FellowNomination.objects.accepted().count(), 1) diff --git a/nominations/tests/test_review_views.py b/nominations/tests/test_review_views.py new file mode 100644 index 000000000..8c2332229 --- /dev/null +++ b/nominations/tests/test_review_views.py @@ -0,0 +1,267 @@ +import datetime +from unittest.mock import patch + +from django.contrib.auth.models import Group +from django.test import Client, TestCase +from django.urls import reverse +from django.utils import timezone + +from nominations.models import FellowNomination, FellowNominationVote +from nominations.tests.factories import ( + FellowNominationFactory, + FellowNominationRoundFactory, + UserFactory, +) + + +class FellowNominationReviewViewTests(TestCase): + def setUp(self): + self.client = Client() + self.wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + self.wg_user = UserFactory() + self.wg_user.groups.add(self.wg_group) + self.regular_user = UserFactory() + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + self.url = reverse("nominations:fellow_nomination_review") + self.client.login(username=self.wg_user.username, password="testpass123") + + def test_wg_member_can_access(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_non_wg_user_gets_403(self): + self.client.login(username=self.regular_user.username, password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 403) + + def test_staff_can_access(self): + staff_user = UserFactory(is_staff=True) + self.client.login(username=staff_user.username, password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_login_required(self): + self.client.logout() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + self.assertIn("/accounts/login/", response.url) + + @patch("nominations.managers.timezone.now") + def test_active_view_default(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 2, 1, 12, 0)) + # Create an active nomination (pending with valid expiry) + expiry_round = FellowNominationRoundFactory( + year=2026, + quarter=4, + quarter_start=datetime.date(2026, 10, 1), + quarter_end=datetime.date(2026, 12, 31), + nominations_cutoff=datetime.date(2026, 11, 20), + review_start=datetime.date(2026, 11, 20), + review_end=datetime.date(2026, 12, 20), + ) + active_nom = FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.PENDING, + expiry_round=expiry_round, + ) + # Create an accepted nomination (not active) + accepted_nom = FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.ACCEPTED, + expiry_round=expiry_round, + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + nominations = list(response.context["nominations"]) + self.assertIn(active_nom, nominations) + self.assertNotIn(accepted_nom, nominations) + + @patch("nominations.managers.timezone.now") + def test_all_view(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 2, 1, 12, 0)) + expiry_round = FellowNominationRoundFactory( + year=2026, + quarter=4, + quarter_start=datetime.date(2026, 10, 1), + quarter_end=datetime.date(2026, 12, 31), + nominations_cutoff=datetime.date(2026, 11, 20), + review_start=datetime.date(2026, 11, 20), + review_end=datetime.date(2026, 12, 20), + ) + pending_nom = FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.PENDING, + expiry_round=expiry_round, + ) + accepted_nom = FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.ACCEPTED, + expiry_round=expiry_round, + ) + response = self.client.get(self.url + "?view=all") + self.assertEqual(response.status_code, 200) + nominations = list(response.context["nominations"]) + self.assertIn(pending_nom, nominations) + self.assertIn(accepted_nom, nominations) + + @patch("nominations.managers.timezone.now") + def test_round_filter(self, mock_now): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 2, 1, 12, 0)) + round_q2 = FellowNominationRoundFactory( + year=2026, + quarter=2, + quarter_start=datetime.date(2026, 4, 1), + quarter_end=datetime.date(2026, 6, 30), + nominations_cutoff=datetime.date(2026, 5, 20), + review_start=datetime.date(2026, 5, 20), + review_end=datetime.date(2026, 6, 20), + ) + expiry_round = FellowNominationRoundFactory( + year=2026, + quarter=4, + quarter_start=datetime.date(2026, 10, 1), + quarter_end=datetime.date(2026, 12, 31), + nominations_cutoff=datetime.date(2026, 11, 20), + review_start=datetime.date(2026, 11, 20), + review_end=datetime.date(2026, 12, 20), + ) + nom_q1 = FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.PENDING, + expiry_round=expiry_round, + ) + nom_q2 = FellowNominationFactory( + nomination_round=round_q2, + status=FellowNomination.PENDING, + expiry_round=expiry_round, + ) + response = self.client.get(self.url + "?view=all&round=2026-q1") + self.assertEqual(response.status_code, 200) + nominations = list(response.context["nominations"]) + self.assertIn(nom_q1, nominations) + self.assertNotIn(nom_q2, nominations) + + +class FellowNominationStatusUpdateViewTests(TestCase): + def setUp(self): + self.client = Client() + self.wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + self.wg_user = UserFactory() + self.wg_user.groups.add(self.wg_group) + self.regular_user = UserFactory() + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + self.nomination = FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.PENDING, + ) + self.url = reverse( + "nominations:fellow_nomination_status_update", + kwargs={"pk": self.nomination.pk}, + ) + self.client.login(username=self.wg_user.username, password="testpass123") + + def test_wg_member_can_update_status(self): + response = self.client.post(self.url, {"status": FellowNomination.UNDER_REVIEW}) + self.assertEqual(response.status_code, 302) + self.nomination.refresh_from_db() + self.assertEqual(self.nomination.status, FellowNomination.UNDER_REVIEW) + + def test_non_wg_user_gets_403(self): + self.client.login(username=self.regular_user.username, password="testpass123") + response = self.client.post(self.url, {"status": FellowNomination.UNDER_REVIEW}) + self.assertEqual(response.status_code, 403) + + @patch("nominations.views.FellowNominationAcceptedNotification.notify") + def test_notification_sent_on_accept(self, mock_notify): + response = self.client.post(self.url, {"status": FellowNomination.ACCEPTED}) + self.assertEqual(response.status_code, 302) + self.nomination.refresh_from_db() + self.assertEqual(self.nomination.status, FellowNomination.ACCEPTED) + mock_notify.assert_called_once() + + @patch("nominations.views.FellowNominationNotAcceptedNotification.notify") + def test_notification_sent_on_not_accept(self, mock_notify): + response = self.client.post(self.url, {"status": FellowNomination.NOT_ACCEPTED}) + self.assertEqual(response.status_code, 302) + self.nomination.refresh_from_db() + self.assertEqual(self.nomination.status, FellowNomination.NOT_ACCEPTED) + mock_notify.assert_called_once() + + +class FellowNominationVoteViewTests(TestCase): + def setUp(self): + self.client = Client() + self.wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + self.wg_user = UserFactory() + self.wg_user.groups.add(self.wg_group) + self.regular_user = UserFactory() + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + self.nomination = FellowNominationFactory( + nomination_round=self.round, + status=FellowNomination.UNDER_REVIEW, + ) + self.url = reverse( + "nominations:fellow_nomination_vote", + kwargs={"pk": self.nomination.pk}, + ) + self.client.login(username=self.wg_user.username, password="testpass123") + + def test_wg_member_can_vote(self): + response = self.client.post(self.url, {"vote": "yes", "comment": ""}) + self.assertEqual(response.status_code, 302) + self.assertEqual(FellowNominationVote.objects.count(), 1) + vote = FellowNominationVote.objects.first() + self.assertEqual(vote.voter, self.wg_user) + self.assertEqual(vote.nomination, self.nomination) + self.assertEqual(vote.vote, "yes") + + def test_non_wg_user_gets_403(self): + self.client.login(username=self.regular_user.username, password="testpass123") + response = self.client.post(self.url, {"vote": "yes", "comment": ""}) + self.assertEqual(response.status_code, 403) + + def test_duplicate_vote_handled(self): + # First vote succeeds + self.client.post(self.url, {"vote": "yes", "comment": ""}) + self.assertEqual(FellowNominationVote.objects.count(), 1) + # Second vote on same nomination triggers IntegrityError handling + response = self.client.post(self.url, {"vote": "no", "comment": ""}) + # View catches IntegrityError and redirects with error message + self.assertEqual(response.status_code, 302) + # Still only one vote in the database + self.assertEqual(FellowNominationVote.objects.count(), 1) + + def test_vote_with_comment(self): + response = self.client.post( + self.url, + {"vote": "no", "comment": "Needs more community involvement."}, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(FellowNominationVote.objects.count(), 1) + vote = FellowNominationVote.objects.first() + self.assertEqual(vote.vote, "no") + self.assertEqual(vote.comment, "Needs more community involvement.") diff --git a/nominations/tests/test_roster_views.py b/nominations/tests/test_roster_views.py new file mode 100644 index 000000000..b7db184a5 --- /dev/null +++ b/nominations/tests/test_roster_views.py @@ -0,0 +1,133 @@ +from django.test import Client, TestCase +from django.urls import reverse + +from nominations.models import Fellow + + +class FellowsRosterViewTests(TestCase): + def setUp(self): + self.client = Client() + self.url = reverse("fellows-roster") + self.alt_url = reverse("fellows-roster-alt") + + def test_public_access_no_login_required(self): + """Roster page should be publicly accessible without login.""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_alt_url_redirects(self): + """The alternate URL /psf/fellows-roster/ should 301 redirect to the canonical URL.""" + response = self.client.get(self.alt_url) + self.assertEqual(response.status_code, 301) + self.assertEqual(response.url, "/psf/fellows/") + + def test_only_fellows_shown(self): + """All Fellow records should appear on the roster.""" + Fellow.objects.create(name="Alice Fellow", year_elected=2020, status="active") + Fellow.objects.create(name="Bob Emeritus", year_elected=2015, status="emeritus") + response = self.client.get(self.url) + self.assertContains(response, "Alice Fellow") + self.assertContains(response, "Bob Emeritus") + + def test_alphabetical_ordering(self): + """Fellows should be ordered by name.""" + Fellow.objects.create(name="Zara Zebra", year_elected=2020, status="active") + Fellow.objects.create(name="Alice Alpha", year_elected=2019, status="active") + Fellow.objects.create(name="Mike Middle", year_elected=2018, status="active") + response = self.client.get(self.url) + content = response.content.decode() + pos_alice = content.index("Alice Alpha") + pos_mike = content.index("Mike Middle") + pos_zara = content.index("Zara Zebra") + self.assertLess(pos_alice, pos_mike) + self.assertLess(pos_mike, pos_zara) + + def test_total_count_in_context(self): + """The context should include the total count of Fellows.""" + for i in range(3): + Fellow.objects.create(name=f"Fellow{i} User{i}", year_elected=2020, status="active") + response = self.client.get(self.url) + self.assertEqual(response.context["total_count"], 3) + + def test_empty_roster(self): + """When there are no Fellows, an appropriate message should be shown.""" + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "No PSF Fellows found") + + def test_year_displayed(self): + """Fellow year elected should be displayed in parentheses.""" + Fellow.objects.create(name="Year Fellow", year_elected=2019, status="active") + response = self.client.get(self.url) + self.assertContains(response, "(2019)") + + def test_emeritus_year_displayed(self): + """Emeritus fellows should show elected year, en-dash, and emeritus year.""" + Fellow.objects.create(name="Old Fellow", year_elected=2005, status="emeritus", emeritus_year=2020) + response = self.client.get(self.url) + self.assertContains(response, "Old Fellow") + # Template uses – HTML entity for en-dash separator + self.assertContains(response, "(2005–2020)") + + def test_deceased_notes_displayed(self): + """Deceased fellows should show notes if present.""" + Fellow.objects.create( + name="Remembered Fellow", + year_elected=2010, + status="deceased", + notes="A great contributor.", + ) + response = self.client.get(self.url) + self.assertContains(response, "Remembered Fellow") + self.assertContains(response, "A great contributor.") + + def test_sections_in_context(self): + """Context should include separate querysets for each status.""" + Fellow.objects.create(name="Active One", year_elected=2020, status="active") + Fellow.objects.create(name="Active Two", year_elected=2019, status="active") + Fellow.objects.create(name="Emeritus One", year_elected=2010, status="emeritus") + Fellow.objects.create(name="Deceased One", year_elected=2005, status="deceased") + response = self.client.get(self.url) + self.assertEqual(response.context["active_count"], 2) + self.assertEqual(response.context["emeritus_count"], 1) + self.assertEqual(response.context["deceased_count"], 1) + self.assertEqual(response.context["total_count"], 4) + + def test_status_tabs_rendered(self): + """Status tab buttons should appear when fellows exist.""" + Fellow.objects.create(name="Active Fellow", year_elected=2020, status="active") + Fellow.objects.create(name="Emeritus Fellow", year_elected=2010, status="emeritus") + Fellow.objects.create(name="Deceased Fellow", year_elected=2005, status="deceased") + response = self.client.get(self.url) + self.assertContains(response, "Active (1)") + self.assertContains(response, "Emeritus (1)") + self.assertContains(response, "In Memoriam (1)") + + def test_years_in_context(self): + """Context should include distinct years sorted descending.""" + Fellow.objects.create(name="Fellow A", year_elected=2015, status="active") + Fellow.objects.create(name="Fellow B", year_elected=2020, status="active") + Fellow.objects.create(name="Fellow C", year_elected=2015, status="emeritus") + response = self.client.get(self.url) + years = list(response.context["years"]) + self.assertEqual(years, [2020, 2015]) + + def test_data_attributes_rendered(self): + """Each fellow list item should have data-name, data-year, data-status attributes.""" + Fellow.objects.create(name="Data Fellow", year_elected=2021, status="active") + response = self.client.get(self.url) + self.assertContains(response, 'data-name="data fellow"') + self.assertContains(response, 'data-year="2021"') + self.assertContains(response, 'data-status="active"') + + def test_emeritus_badge_shown(self): + """Emeritus fellows should have a badge.""" + Fellow.objects.create(name="Badge Fellow", year_elected=2010, status="emeritus") + response = self.client.get(self.url) + self.assertContains(response, "fellow-badge emeritus") + + def test_deceased_badge_shown(self): + """Deceased fellows should have a badge.""" + Fellow.objects.create(name="Memorial Fellow", year_elected=2005, status="deceased") + response = self.client.get(self.url) + self.assertContains(response, "fellow-badge deceased") diff --git a/nominations/tests/test_views.py b/nominations/tests/test_views.py new file mode 100644 index 000000000..a240ab806 --- /dev/null +++ b/nominations/tests/test_views.py @@ -0,0 +1,172 @@ +import datetime +from unittest.mock import patch + +from django.contrib.auth.models import Group +from django.test import Client, TestCase +from django.urls import reverse +from django.utils import timezone + +from nominations.models import Fellow, FellowNomination +from nominations.tests.factories import ( + FellowNominationFactory, + FellowNominationRoundFactory, + UserFactory, +) + + +class FellowNominationCreateViewTests(TestCase): + def setUp(self): + self.client = Client() + self.user = UserFactory() + self.client.login(username=self.user.username, password="testpass123") + self.round = FellowNominationRoundFactory( + year=2026, + quarter=1, + quarter_start=datetime.date(2026, 1, 1), + quarter_end=datetime.date(2026, 3, 31), + nominations_cutoff=datetime.date(2026, 2, 20), + review_start=datetime.date(2026, 2, 20), + review_end=datetime.date(2026, 3, 20), + ) + self.url = reverse("nominations:fellow_nomination_create") + + def test_login_required(self): + self.client.logout() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + self.assertIn("/accounts/login/", response.url) + + def test_get_with_open_round(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Nominate a PSF Fellow") + + def test_404_when_no_open_round(self): + self.round.is_open = False + self.round.save() + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + + @patch("nominations.views.FellowNominationSubmittedToNominator.notify") + @patch("nominations.views.FellowNominationSubmittedToWG.notify") + @patch("nominations.models.timezone.now") + def test_successful_submission(self, mock_now, mock_wg_notify, mock_nominator_notify): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 1, 15, 12, 0)) + data = { + "nominee_name": "Jane Doe", + "nominee_email": "jane@example.com", + "nomination_statement": "Jane has made outstanding contributions to the Python community through years of dedicated work on documentation, mentoring, and conference organization.", + "nomination_statement_markup_type": "markdown", + } + response = self.client.post(self.url, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(FellowNomination.objects.count(), 1) + nom = FellowNomination.objects.first() + self.assertEqual(nom.nominator, self.user) + self.assertEqual(nom.nomination_round, self.round) + + @patch("nominations.views.FellowNominationSubmittedToNominator.notify") + @patch("nominations.views.FellowNominationSubmittedToWG.notify") + @patch("nominations.models.timezone.now") + def test_fellow_warning_shown(self, mock_now, mock_wg_notify, mock_nominator_notify): + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 1, 15, 12, 0)) + fellow_user = UserFactory(email="fellow@example.com") + Fellow.objects.create( + name="Fellow User", + year_elected=2020, + user=fellow_user, + ) + data = { + "nominee_name": "Fellow User", + "nominee_email": "fellow@example.com", + "nomination_statement": "This person has been an incredible contributor to the Python community through years of sustained effort across multiple projects and initiatives.", + "nomination_statement_markup_type": "markdown", + } + self.client.post(self.url, data, follow=True) + self.assertEqual(FellowNomination.objects.count(), 1) + nom = FellowNomination.objects.first() + self.assertTrue(nom.nominee_is_fellow_at_submission) + self.assertEqual(nom.nominee_user, fellow_user) + + +class FellowNominationDetailViewTests(TestCase): + def setUp(self): + self.client = Client() + self.round = FellowNominationRoundFactory() + self.nominator = UserFactory() + self.nomination = FellowNominationFactory( + nominator=self.nominator, + nomination_round=self.round, + ) + self.url = reverse( + "nominations:fellow_nomination_detail", + kwargs={"pk": self.nomination.pk}, + ) + + def test_login_required(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + + def test_nominator_can_view(self): + self.client.login(username=self.nominator.username, password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_staff_can_view(self): + staff = UserFactory(is_staff=True) + self.client.login(username=staff.username, password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_wg_member_can_view(self): + wg_user = UserFactory() + group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + wg_user.groups.add(group) + self.client.login(username=wg_user.username, password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + + def test_random_user_cannot_view(self): + random_user = UserFactory() + self.client.login(username=random_user.username, password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 404) + + +class MyFellowNominationsViewTests(TestCase): + def setUp(self): + self.client = Client() + self.user = UserFactory() + self.round = FellowNominationRoundFactory() + self.url = reverse("nominations:fellow_my_nominations") + + def test_login_required(self): + response = self.client.get(self.url) + self.assertEqual(response.status_code, 302) + + def test_shows_own_nominations(self): + self.client.login(username=self.user.username, password="testpass123") + nom = FellowNominationFactory( + nominator=self.user, + nomination_round=self.round, + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, nom.nominee_name) + + def test_does_not_show_other_users_nominations(self): + self.client.login(username=self.user.username, password="testpass123") + other_user = UserFactory() + nom = FellowNominationFactory( + nominator=other_user, + nomination_round=self.round, + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertNotContains(response, nom.nominee_name) + + def test_empty_state(self): + self.client.login(username=self.user.username, password="testpass123") + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "You have not submitted any Fellow nominations yet.") diff --git a/nominations/urls.py b/nominations/urls.py index 1815ae2e7..de23c2cc5 100644 --- a/nominations/urls.py +++ b/nominations/urls.py @@ -1,26 +1,34 @@ -from . import views from django.urls import path +from nominations import views + app_name = "nominations" urlpatterns = [ - path('elections/', views.ElectionsList.as_view(), name="elections_list"), - path('election//', views.ElectionDetail.as_view(), name="election_detail"), - path('elections//nominees/', views.NomineeList.as_view(), - name="nominees_list", - ), - path('elections//nominees//', views.NomineeDetail.as_view(), - name="nominee_detail", - ), - path('/create/', views.NominationCreate.as_view(), - name="nomination_create", - ), - path('//', views.NominationView.as_view(), - name="nomination_detail", - ), - path('//edit/', views.NominationEdit.as_view(), - name="nomination_edit", - ), - path('//accept/', views.NominationAccept.as_view(), - name="nomination_accept", + path("elections/", views.ElectionsList.as_view(), name="elections_list"), + path("election//", views.ElectionDetail.as_view(), name="election_detail"), + path("elections//nominees/", views.NomineeList.as_view(), name="nominees_list"), + path("elections//nominees//", views.NomineeDetail.as_view(), name="nominee_detail"), + path("/create/", views.NominationCreate.as_view(), name="nomination_create"), + path("//", views.NominationView.as_view(), name="nomination_detail"), + path("//edit/", views.NominationEdit.as_view(), name="nomination_edit"), + path("//accept/", views.NominationAccept.as_view(), name="nomination_accept"), + # Fellow Nominations + path("fellows/nominate/", views.FellowNominationCreate.as_view(), name="fellow_nomination_create"), + path("fellows/my-nominations/", views.MyFellowNominations.as_view(), name="fellow_my_nominations"), + path("fellows/nomination//", views.FellowNominationDetail.as_view(), name="fellow_nomination_detail"), + # Fellow WG Management + path("fellows/review/", views.FellowNominationReview.as_view(), name="fellow_nomination_review"), + path( + "fellows/nomination//status/", + views.FellowNominationStatusUpdate.as_view(), + name="fellow_nomination_status_update", ), + path("fellows/nomination//vote/", views.FellowNominationVoteView.as_view(), name="fellow_nomination_vote"), + path("fellows/manage/", views.FellowNominationDashboard.as_view(), name="fellow_nomination_dashboard"), + path("fellows/manage/rounds/", views.FellowNominationRoundList.as_view(), name="fellow_round_list"), + path("fellows/manage/rounds/create/", views.FellowNominationRoundCreate.as_view(), name="fellow_round_create"), + path("fellows/manage/rounds//edit/", views.FellowNominationRoundUpdate.as_view(), name="fellow_round_update"), + path("fellows/manage/rounds//toggle/", views.FellowNominationRoundToggle.as_view(), name="fellow_round_toggle"), + path("fellows/manage/nomination//edit/", views.FellowNominationEdit.as_view(), name="fellow_nomination_edit"), + path("fellows/manage/nomination//delete/", views.FellowNominationDelete.as_view(), name="fellow_nomination_delete"), ] diff --git a/nominations/views.py b/nominations/views.py index 570d89c48..942b2c628 100644 --- a/nominations/views.py +++ b/nominations/views.py @@ -1,14 +1,47 @@ from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin - -from django.views.generic import CreateView, UpdateView, DetailView, ListView +from django.db import IntegrityError, transaction +from django.db.models import Count, Q +from django.http import Http404, HttpResponseNotAllowed +from django.shortcuts import get_object_or_404, redirect from django.urls import reverse -from django.http import Http404 - -from pydotorg.mixins import LoginRequiredMixin - -from .models import Nomination, Nominee, Election -from .forms import NominationForm, NominationCreateForm, NominationAcceptForm +from django.views import View +from django.views.generic import ( + CreateView, + DeleteView, + DetailView, + ListView, + TemplateView, + UpdateView, +) + +from pydotorg.mixins import GroupRequiredMixin, LoginRequiredMixin + +from nominations.forms import ( + FellowNominationForm, + FellowNominationManageForm, + FellowNominationRoundForm, + FellowNominationStatusForm, + FellowNominationVoteForm, + NominationAcceptForm, + NominationCreateForm, + NominationForm, +) +from nominations.models import ( + Election, + Fellow, + FellowNomination, + FellowNominationRound, + FellowNominationVote, + Nomination, + Nominee, +) +from nominations.notifications import ( + FellowNominationAcceptedNotification, + FellowNominationNotAcceptedNotification, + FellowNominationSubmittedToNominator, + FellowNominationSubmittedToWG, +) class ElectionsList(ListView): @@ -196,3 +229,429 @@ def get_queryset(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) return context + + +# --- Fellow Nomination Views --- + + +class FellowWGRequiredMixin(GroupRequiredMixin): + """Restrict access to PSF Fellow Work Group members (and staff).""" + + group_required = "PSF Fellow Work Group" + raise_exception = True + + def check_membership(self, group): + if self.request.user.is_staff or self.request.user.is_superuser: + return True + return super().check_membership(group) + + +class FellowNominationCreate(LoginRequiredMixin, CreateView): + """Submit a new PSF Fellow nomination.""" + + model = FellowNomination + form_class = FellowNominationForm + template_name = "nominations/fellow_nomination_form.html" + login_message = "Please login to submit a Fellow nomination." + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["request"] = self.request + return kwargs + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + current_round = FellowNominationRound.objects.filter(is_open=True).first() + if current_round is None or not current_round.is_accepting_nominations: + raise Http404("Fellow nominations are not currently open.") + context["nomination_round"] = current_round + return context + + def form_valid(self, form): + current_round = FellowNominationRound.objects.filter(is_open=True).first() + if current_round is None or not current_round.is_accepting_nominations: + raise Http404("Fellow nominations are not currently open.") + + form.instance.nominator = self.request.user + form.instance.nomination_round = current_round + + # Compute expiry round (4 quarters later = current quarter + 3) + expiry_year = current_round.year + expiry_quarter = current_round.quarter + 3 + if expiry_quarter > 4: + expiry_year += (expiry_quarter - 1) // 4 + expiry_quarter = ((expiry_quarter - 1) % 4) + 1 + form.instance.expiry_round = FellowNominationRound.objects.filter( + year=expiry_year, quarter=expiry_quarter + ).first() + + # Cross-reference nominee_email against User table + from users.models import User + nominee_email = form.cleaned_data["nominee_email"] + try: + nominee_user = User.objects.get(email__iexact=nominee_email) + form.instance.nominee_user = nominee_user + # Check if nominee is already a Fellow + try: + if nominee_user.fellow is not None: + form.instance.nominee_is_fellow_at_submission = True + messages.warning( + self.request, + f"{form.cleaned_data['nominee_name']} is already a PSF Fellow. " + "The nomination has been saved but may not need further action.", + ) + except Fellow.DoesNotExist: + pass + except User.DoesNotExist: + pass + + response = super().form_valid(form) + + # Send email notifications + FellowNominationSubmittedToNominator().notify( + nomination=self.object, request=self.request + ) + FellowNominationSubmittedToWG().notify( + nomination=self.object, request=self.request + ) + + messages.success( + self.request, + "Your Fellow nomination has been submitted successfully. " + "You can track its status on your nominations page.", + ) + return response + + def get_success_url(self): + return reverse("nominations:fellow_my_nominations") + + +class FellowNominationDetail(LoginRequiredMixin, DetailView): + """View details of a Fellow nomination.""" + + model = FellowNomination + template_name = "nominations/fellow_nomination_detail.html" + context_object_name = "nomination" + + def get_object(self, queryset=None): + obj = super().get_object(queryset) + user = self.request.user + # Visible to: nominator, staff, superuser, PSF Fellow Work Group members + if user == obj.nominator or user.is_staff or user.is_superuser: + return obj + if user.groups.filter(name="PSF Fellow Work Group").exists(): + return obj + raise Http404 + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + user = self.request.user + in_wg_group = user.groups.filter(name="PSF Fellow Work Group").exists() + is_wg_member = in_wg_group or user.is_staff or user.is_superuser + context["is_wg_member"] = is_wg_member + + if is_wg_member: + nomination = self.object + votes = nomination.votes.select_related("voter").all() + context["votes"] = votes + context["user_vote"] = nomination.votes.filter(voter=user).first() + context["vote_result"] = nomination.vote_result + context["yes_count"] = votes.filter(vote="yes").count() + context["no_count"] = votes.filter(vote="no").count() + context["abstain_count"] = votes.filter(vote="abstain").count() + + return context + + +class MyFellowNominations(LoginRequiredMixin, ListView): + """List the current user's Fellow nominations.""" + + template_name = "nominations/fellow_my_nominations.html" + context_object_name = "nominations" + + def get_queryset(self): + return FellowNomination.objects.filter( + nominator=self.request.user + ).select_related("nomination_round", "expiry_round") + + +# --- Fellow WG Management Views --- + + +class FellowNominationDashboard(LoginRequiredMixin, FellowWGRequiredMixin, TemplateView): + """Dashboard overview for PSF Fellow Work Group members.""" + + template_name = "nominations/fellow_nomination_dashboard.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + current_round = FellowNominationRound.objects.filter(is_open=True).first() + context["current_round"] = current_round + + if current_round: + round_nominations = FellowNomination.objects.filter( + nomination_round=current_round + ) + context["total_nominations"] = round_nominations.count() + context["pending_count"] = round_nominations.filter( + status=FellowNomination.PENDING + ).count() + context["under_review_count"] = round_nominations.filter( + status=FellowNomination.UNDER_REVIEW + ).count() + context["accepted_count"] = round_nominations.filter( + status=FellowNomination.ACCEPTED + ).count() + context["not_accepted_count"] = round_nominations.filter( + status=FellowNomination.NOT_ACCEPTED + ).count() + needs_your_vote = round_nominations.filter( + status=FellowNomination.UNDER_REVIEW + ).exclude( + votes__voter=self.request.user + ) + context["needs_votes_count"] = needs_your_vote.count() + context["needs_votes_nominations"] = needs_your_vote.select_related( + "nomination_round" + ) + + context["recent_rounds"] = FellowNominationRound.objects.all()[:4] + return context + + +class FellowNominationReview(LoginRequiredMixin, FellowWGRequiredMixin, ListView): + """Review list of Fellow nominations for WG members.""" + + template_name = "nominations/fellow_nomination_review.html" + context_object_name = "nominations" + + def get_queryset(self): + view_mode = self.request.GET.get("view", "active") + round_slug = self.request.GET.get("round") + + if view_mode == "all": + qs = FellowNomination.objects.all() + else: + qs = FellowNomination.objects.active() + + if round_slug: + qs = qs.filter(nomination_round__slug=round_slug) + + return qs.select_related( + "nomination_round", "nominator" + ).annotate( + yes_count=Count("votes", filter=Q(votes__vote="yes")), + no_count=Count("votes", filter=Q(votes__vote="no")), + abstain_count=Count("votes", filter=Q(votes__vote="abstain")), + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["rounds"] = FellowNominationRound.objects.all() + context["current_view"] = self.request.GET.get("view", "active") + context["selected_round"] = self.request.GET.get("round", "") + return context + + +class FellowNominationStatusUpdate(LoginRequiredMixin, FellowWGRequiredMixin, UpdateView): + """Update the status of a Fellow nomination.""" + + model = FellowNomination + form_class = FellowNominationStatusForm + template_name = "nominations/fellow_nomination_status_form.html" + + def form_valid(self, form): + old_status = FellowNomination.objects.filter(pk=self.object.pk).values_list( + "status", flat=True + ).first() + form.instance.last_modified_by = self.request.user + response = super().form_valid(form) + + new_status = self.object.status + if old_status != new_status: + messages.success( + self.request, + f"Status updated to '{self.object.get_status_display()}' for {self.object.nominee_name}.", + ) + if new_status == FellowNomination.ACCEPTED: + FellowNominationAcceptedNotification().notify( + nomination=self.object, request=self.request + ) + elif new_status == FellowNomination.NOT_ACCEPTED: + FellowNominationNotAcceptedNotification().notify( + nomination=self.object, request=self.request + ) + else: + messages.info(self.request, "No status change was made.") + + return response + + def get_success_url(self): + return reverse( + "nominations:fellow_nomination_detail", kwargs={"pk": self.object.pk} + ) + + +class FellowNominationVoteView(LoginRequiredMixin, FellowWGRequiredMixin, CreateView): + """Cast a vote on a Fellow nomination.""" + + model = FellowNominationVote + form_class = FellowNominationVoteForm + template_name = "nominations/fellow_nomination_vote_form.html" + + def get_nomination(self): + return get_object_or_404(FellowNomination, pk=self.kwargs["pk"]) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["nomination"] = self.get_nomination() + return context + + def form_valid(self, form): + nomination = self.get_nomination() + if nomination.status != FellowNomination.UNDER_REVIEW: + messages.error( + self.request, + "Votes can only be cast on nominations that are under review.", + ) + return redirect( + "nominations:fellow_nomination_detail", pk=nomination.pk + ) + form.instance.voter = self.request.user + form.instance.nomination = nomination + try: + with transaction.atomic(): + response = super().form_valid(form) + messages.success( + self.request, + f"Your vote on {nomination.nominee_name} has been recorded.", + ) + return response + except IntegrityError: + messages.error( + self.request, + "You have already voted on this nomination.", + ) + return redirect( + "nominations:fellow_nomination_detail", pk=nomination.pk + ) + + def get_success_url(self): + return reverse( + "nominations:fellow_nomination_detail", + kwargs={"pk": self.object.nomination.pk}, + ) + + +class FellowNominationRoundList(LoginRequiredMixin, FellowWGRequiredMixin, ListView): + """List all Fellow nomination rounds.""" + + model = FellowNominationRound + template_name = "nominations/fellow_round_list.html" + context_object_name = "rounds" + + def get_queryset(self): + return FellowNominationRound.objects.annotate( + nomination_count=Count("nominations") + ) + + +class FellowNominationRoundCreate(LoginRequiredMixin, FellowWGRequiredMixin, CreateView): + """Create a new Fellow nomination round.""" + + model = FellowNominationRound + form_class = FellowNominationRoundForm + template_name = "nominations/fellow_round_form.html" + + def get_success_url(self): + return reverse("nominations:fellow_round_list") + + +class FellowNominationRoundUpdate(LoginRequiredMixin, FellowWGRequiredMixin, UpdateView): + """Edit an existing Fellow nomination round.""" + + model = FellowNominationRound + form_class = FellowNominationRoundForm + template_name = "nominations/fellow_round_form.html" + slug_field = "slug" + slug_url_kwarg = "slug" + + def get_success_url(self): + return reverse("nominations:fellow_round_list") + + +class FellowNominationRoundToggle(LoginRequiredMixin, FellowWGRequiredMixin, View): + """Toggle the is_open flag on a Fellow nomination round (POST only).""" + + def get(self, request, *args, **kwargs): + return HttpResponseNotAllowed(["POST"]) + + def post(self, request, *args, **kwargs): + nomination_round = get_object_or_404( + FellowNominationRound, slug=kwargs["slug"] + ) + nomination_round.is_open = not nomination_round.is_open + nomination_round.save() + return redirect("nominations:fellow_round_list") + + +class FellowNominationEdit(LoginRequiredMixin, FellowWGRequiredMixin, UpdateView): + """Full edit form for WG members to manage a Fellow nomination.""" + + model = FellowNomination + form_class = FellowNominationManageForm + template_name = "nominations/fellow_nomination_manage_form.html" + + def form_valid(self, form): + form.instance.last_modified_by = self.request.user + return super().form_valid(form) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["nomination"] = self.object + return context + + def get_success_url(self): + return reverse( + "nominations:fellow_nomination_detail", kwargs={"pk": self.object.pk} + ) + + +class FellowNominationDelete(LoginRequiredMixin, FellowWGRequiredMixin, DeleteView): + """Delete a Fellow nomination (WG only).""" + + model = FellowNomination + template_name = "nominations/fellow_nomination_confirm_delete.html" + + def get_success_url(self): + return reverse("nominations:fellow_nomination_review") + + +# --- Fellows Roster (Public) --- + + +class FellowsRoster(ListView): + """Public roster of PSF Fellows.""" + + template_name = "nominations/fellows_roster.html" + context_object_name = "fellows" + + def get_queryset(self): + return Fellow.objects.all() + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + qs = context["fellows"] + context["active_fellows"] = qs.filter(status=Fellow.ACTIVE) + context["emeritus_fellows"] = qs.filter(status=Fellow.EMERITUS) + context["deceased_fellows"] = qs.filter(status=Fellow.DECEASED) + context["active_count"] = context["active_fellows"].count() + context["emeritus_count"] = context["emeritus_fellows"].count() + context["deceased_count"] = context["deceased_fellows"].count() + context["total_count"] = qs.count() + context["years"] = ( + Fellow.objects.values_list("year_elected", flat=True) + .distinct() + .order_by("-year_elected") + ) + return context \ No newline at end of file diff --git a/pydotorg/settings/base.py b/pydotorg/settings/base.py index 0fac91eb1..04d6d4e62 100644 --- a/pydotorg/settings/base.py +++ b/pydotorg/settings/base.py @@ -43,6 +43,8 @@ CELERY_BROKER_URL = _REDIS_URL CELERY_RESULT_BACKEND = _REDIS_URL +from celery.schedules import crontab + CELERY_BEAT_SCHEDULE = { # "example-management-command": { # "task": "pydotorg.celery.run_management_command", @@ -52,6 +54,10 @@ # 'example-task': { # 'task': 'users.tasks.example_task', # }, + 'close-expired-fellow-nominations': { + 'task': 'nominations.tasks.close_expired_fellow_nominations', + 'schedule': crontab(hour=0, minute=0, day_of_month=1), + }, } ### Locale settings @@ -304,6 +310,10 @@ ) PYPI_SPONSORS_CSV = os.path.join(BASE, "data", "pypi-sponsors.csv") +FELLOW_WG_NOTIFICATION_EMAIL = config( + "FELLOW_WG_NOTIFICATION_EMAIL", default="psf-fellow@python.org" +) + # Mail DEFAULT_FROM_EMAIL = 'noreply@python.org' diff --git a/pydotorg/settings/local.py b/pydotorg/settings/local.py index e4faa3f44..33684f0f3 100644 --- a/pydotorg/settings/local.py +++ b/pydotorg/settings/local.py @@ -32,7 +32,9 @@ }, } -EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = config('EMAIL_HOST', default='maildev') +EMAIL_PORT = config('EMAIL_PORT', default=1025, cast=int) # Use Dummy SASS compiler to avoid performance issues and remove the need to # have a sass compiler installed at all during local development if you aren't diff --git a/pydotorg/urls.py b/pydotorg/urls.py index be51ab09a..b6d1111f0 100644 --- a/pydotorg/urls.py +++ b/pydotorg/urls.py @@ -4,7 +4,7 @@ from django.conf.urls.static import static from django.urls import include from django.urls import path, re_path -from django.views.generic.base import TemplateView +from django.views.generic.base import RedirectView, TemplateView from django.conf import settings from cms.views import custom_404 @@ -12,6 +12,7 @@ from users.views import HoneypotSignupView, CustomPasswordChangeView from . import views, urls_api +from nominations.views import FellowsRoster handler404 = custom_404 @@ -41,6 +42,8 @@ # other section landing pages path('psf-landing/', TemplateView.as_view(template_name="psf/index.html"), name='psf-landing'), path('psf/sponsors/', TemplateView.as_view(template_name="psf/sponsors-list.html"), name='psf-sponsors'), + path('psf/fellows/', FellowsRoster.as_view(), name='fellows-roster'), + path('psf/fellows-roster/', RedirectView.as_view(pattern_name='fellows-roster', permanent=True), name='fellows-roster-alt'), path('docs-landing/', TemplateView.as_view(template_name="docs/index.html"), name='docs-landing'), path('pypl-landing/', TemplateView.as_view(template_name="pypl/index.html"), name='pypl-landing'), path('shop-landing/', TemplateView.as_view(template_name="shop/index.html"), name='shop-landing'), diff --git a/templates/nominations/email/fellow_nomination_accepted.html b/templates/nominations/email/fellow_nomination_accepted.html new file mode 100644 index 000000000..8fa61c237 --- /dev/null +++ b/templates/nominations/email/fellow_nomination_accepted.html @@ -0,0 +1,163 @@ +{% load i18n %} + + + + + + Fellow Nomination Accepted + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ Python Software Foundation +
+ Fellow Nominations Program +
+
+ + + + +
+ + + + + +
+ Nomination Accepted +
+
+
+ + + + + + + + + + +
+ Hello {{ nomination.nominator.get_full_name|default:nomination.nominator.username }}, +
+ Congratulations! We are pleased to inform you that your PSF Fellow nomination for {{ nomination.nominee_name }} has been accepted by the Fellow Work Group. +
+ {{ nomination.nominee_name }} will be recognized as a PSF Fellow for their outstanding contributions to the Python community. +
+
+ + + + +
+ + + + + + + +
+ Nomination Details +
+ Nominee: {{ nomination.nominee_name }}
+ Round: {{ nomination.nomination_round }}
+ Submitted: {{ nomination.created|date:"N j, Y" }} +
+
+
+ + + + +
+ + + View the Nomination + + +
+
+ + + + +
+ Thank you for taking the time to nominate a deserving member of our community. Nominations like yours help us recognize the people who make Python great. +
+
+ + + + + + + +
+ The Python Software Foundation +
+ 9450 SW Gemini Dr., ECM# 90772, Beaverton, OR 97008, USA +
+
+ + +
+ + + + diff --git a/templates/nominations/email/fellow_nomination_accepted.txt b/templates/nominations/email/fellow_nomination_accepted.txt new file mode 100644 index 000000000..0a82255f7 --- /dev/null +++ b/templates/nominations/email/fellow_nomination_accepted.txt @@ -0,0 +1,17 @@ +Hello {{ nomination.nominator.get_full_name|default:nomination.nominator.username }}, + +Congratulations! We are pleased to inform you that your PSF Fellow nomination for {{ nomination.nominee_name }} has been accepted by the Fellow Work Group. + +Nomination Details: +- Nominee: {{ nomination.nominee_name }} +- Round: {{ nomination.nomination_round }} +- Submitted: {{ nomination.created|date:"N j, Y" }} + +{{ nomination.nominee_name }} will be recognized as a PSF Fellow for their outstanding contributions to the Python community. + +View the nomination: +{{ site_url }}{{ nomination.get_absolute_url }} + +Thank you for taking the time to nominate a deserving member of our community. Nominations like yours help us recognize the people who make Python great. + +- The Python Software Foundation diff --git a/templates/nominations/email/fellow_nomination_accepted_subject.txt b/templates/nominations/email/fellow_nomination_accepted_subject.txt new file mode 100644 index 000000000..45538f094 --- /dev/null +++ b/templates/nominations/email/fellow_nomination_accepted_subject.txt @@ -0,0 +1 @@ +Your PSF Fellow nomination for {{ nomination.nominee_name }} has been accepted diff --git a/templates/nominations/email/fellow_nomination_not_accepted.html b/templates/nominations/email/fellow_nomination_not_accepted.html new file mode 100644 index 000000000..0b5a544ef --- /dev/null +++ b/templates/nominations/email/fellow_nomination_not_accepted.html @@ -0,0 +1,162 @@ +{% load i18n %} + + + + + + Fellow Nomination Update + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ Python Software Foundation +
+ Fellow Nominations Program +
+
+ + + + + + + +
+ Hello {{ nomination.nominator.get_full_name|default:nomination.nominator.username }}, +
+ Thank you for your PSF Fellow nomination for {{ nomination.nominee_name }}. After careful review, the Fellow Work Group has determined that this nomination was not accepted in the {{ nomination.nomination_round }} round. +
+
+ + + + +
+ + + + + + + +
+ Nomination Details +
+ Nominee: {{ nomination.nominee_name }}
+ Round: {{ nomination.nomination_round }}
+ Submitted: {{ nomination.created|date:"N j, Y" }} +
+
+
+ + + + +
+ + + + +
+ Your nomination remains active. Per the Fellow Work Group Charter, nominations remain active for one year (four quarters) from the date of submission. Your nomination will continue to be considered in subsequent review rounds during that period. +
+
+
+ + + + +
+ + + View the Nomination + + +
+
+ + + + + + + +
+ We encourage your continued participation in the Fellow nomination process. The contributions of community members like you help us identify and recognize those who have made outstanding contributions to the Python ecosystem. +
+ If you have any questions, please reach out to the Fellow Work Group. +
+
+ + + + + + + +
+ The Python Software Foundation +
+ 9450 SW Gemini Dr., ECM# 90772, Beaverton, OR 97008, USA +
+
+ + +
+ + + + diff --git a/templates/nominations/email/fellow_nomination_not_accepted.txt b/templates/nominations/email/fellow_nomination_not_accepted.txt new file mode 100644 index 000000000..218d21ecb --- /dev/null +++ b/templates/nominations/email/fellow_nomination_not_accepted.txt @@ -0,0 +1,19 @@ +Hello {{ nomination.nominator.get_full_name|default:nomination.nominator.username }}, + +Thank you for your PSF Fellow nomination for {{ nomination.nominee_name }}. After careful review, the Fellow Work Group has determined that this nomination was not accepted in the {{ nomination.nomination_round }} round. + +Nomination Details: +- Nominee: {{ nomination.nominee_name }} +- Round: {{ nomination.nomination_round }} +- Submitted: {{ nomination.created|date:"N j, Y" }} + +Please note that per the Fellow Work Group Charter, nominations remain active for one year (four quarters) from the date of submission. Your nomination will continue to be considered in subsequent review rounds during that period. + +View the nomination: +{{ site_url }}{{ nomination.get_absolute_url }} + +We encourage your continued participation in the Fellow nomination process. The contributions of community members like you help us identify and recognize those who have made outstanding contributions to the Python ecosystem. + +If you have any questions, please reach out to the Fellow Work Group. + +- The Python Software Foundation diff --git a/templates/nominations/email/fellow_nomination_not_accepted_subject.txt b/templates/nominations/email/fellow_nomination_not_accepted_subject.txt new file mode 100644 index 000000000..95bb567ae --- /dev/null +++ b/templates/nominations/email/fellow_nomination_not_accepted_subject.txt @@ -0,0 +1 @@ +Update on your PSF Fellow nomination for {{ nomination.nominee_name }} diff --git a/templates/nominations/email/fellow_nomination_submitted.html b/templates/nominations/email/fellow_nomination_submitted.html new file mode 100644 index 000000000..305ea2e57 --- /dev/null +++ b/templates/nominations/email/fellow_nomination_submitted.html @@ -0,0 +1,150 @@ +{% load i18n %} + + + + + + PSF Fellow Nomination Submitted + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ Python Software Foundation +
+ Fellow Nominations Program +
+
+ + + + + + + + + + +
+ Hello {{ nomination.nominator.get_full_name|default:nomination.nominator.username }}, +
+ Thank you for submitting a PSF Fellow nomination for {{ nomination.nominee_name }}. +
+ Your nomination has been received and will be reviewed by the PSF Fellow Work Group during the {{ nomination.nomination_round }} review period. +
+
+ + + + +
+ + + + + + + +
+ Nomination Details +
+ Nominee: {{ nomination.nominee_name }}
+ Round: {{ nomination.nomination_round }}
+ Submitted: {{ nomination.created|date:"N j, Y" }} +
+
+
+ + + + + + + +
+ + + View Your Nomination + + +
+ + Track All Your Nominations + +
+
+ + + + +
+ Thank you for contributing to the Python community! +
+
+ + + + + + + +
+ The Python Software Foundation +
+ 9450 SW Gemini Dr., ECM# 90772, Beaverton, OR 97008, USA +
+
+ + +
+ + + + diff --git a/templates/nominations/email/fellow_nomination_submitted.txt b/templates/nominations/email/fellow_nomination_submitted.txt new file mode 100644 index 000000000..3396d3d78 --- /dev/null +++ b/templates/nominations/email/fellow_nomination_submitted.txt @@ -0,0 +1,20 @@ +Hello {{ nomination.nominator.get_full_name|default:nomination.nominator.username }}, + +Thank you for submitting a PSF Fellow nomination for {{ nomination.nominee_name }}. + +Your nomination has been received and will be reviewed by the PSF Fellow Work Group during the {{ nomination.nomination_round }} review period. + +Nomination Details: +- Nominee: {{ nomination.nominee_name }} +- Round: {{ nomination.nomination_round }} +- Submitted: {{ nomination.created|date:"N j, Y" }} + +You can view your nomination at: +{{ site_url }}{{ nomination.get_absolute_url }} + +Track all your nominations at: +{{ site_url }}{% url 'nominations:fellow_my_nominations' %} + +Thank you for contributing to the Python community! + +- The Python Software Foundation diff --git a/templates/nominations/email/fellow_nomination_submitted_subject.txt b/templates/nominations/email/fellow_nomination_submitted_subject.txt new file mode 100644 index 000000000..3f3f0bb24 --- /dev/null +++ b/templates/nominations/email/fellow_nomination_submitted_subject.txt @@ -0,0 +1 @@ +PSF Fellow Nomination Submitted: {{ nomination.nominee_name }} diff --git a/templates/nominations/email/fellow_nomination_submitted_wg.html b/templates/nominations/email/fellow_nomination_submitted_wg.html new file mode 100644 index 000000000..424df8be9 --- /dev/null +++ b/templates/nominations/email/fellow_nomination_submitted_wg.html @@ -0,0 +1,171 @@ +{% load i18n %} + + + + + + New PSF Fellow Nomination + + + + + + + + +
+ + + + + + + + + + + + + + + {% if nomination.nominee_is_fellow_at_submission %} + + + + + {% endif %} + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ Fellow Work Group +
+ New Nomination Alert +
+
+ + + + +
+ A new PSF Fellow nomination has been submitted and requires review. +
+
+ + + + +
+ NOTE: This nominee is already a PSF Fellow. +
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ Nominee + + {{ nomination.nominee_name }} +
+ Nominee Email + + {{ nomination.nominee_email }} +
+ + +
 
+
+ Nominated by + + {{ nomination.nominator.get_full_name|default:nomination.nominator.username }} +
+ {{ nomination.nominator.email }} +
+ Round + + {{ nomination.nomination_round }} +
+
+
+ + + + + + + +
+ + + Review This Nomination + + +
+ + WG Dashboard + +
+
+ + + + +
+ Fellow Work Group Internal Notification — Python Software Foundation +
+
+ + +
+ + + + diff --git a/templates/nominations/email/fellow_nomination_submitted_wg.txt b/templates/nominations/email/fellow_nomination_submitted_wg.txt new file mode 100644 index 000000000..dbc0a19b7 --- /dev/null +++ b/templates/nominations/email/fellow_nomination_submitted_wg.txt @@ -0,0 +1,14 @@ +A new PSF Fellow nomination has been submitted. + +Nominee: {{ nomination.nominee_name }} +Nominee Email: {{ nomination.nominee_email }} +Nominated by: {{ nomination.nominator.get_full_name|default:nomination.nominator.username }} ({{ nomination.nominator.email }}) +Round: {{ nomination.nomination_round }} +{% if nomination.nominee_is_fellow_at_submission %} +NOTE: This nominee is already a PSF Fellow. +{% endif %} +Review this nomination: +{{ site_url }}{{ nomination.get_absolute_url }} + +WG Dashboard: +{{ site_url }}{% url 'nominations:fellow_nomination_dashboard' %} diff --git a/templates/nominations/email/fellow_nomination_submitted_wg_subject.txt b/templates/nominations/email/fellow_nomination_submitted_wg_subject.txt new file mode 100644 index 000000000..f1383fd60 --- /dev/null +++ b/templates/nominations/email/fellow_nomination_submitted_wg_subject.txt @@ -0,0 +1 @@ +New PSF Fellow Nomination: {{ nomination.nominee_name }} diff --git a/templates/nominations/fellow_my_nominations.html b/templates/nominations/fellow_my_nominations.html new file mode 100644 index 000000000..82299d73e --- /dev/null +++ b/templates/nominations/fellow_my_nominations.html @@ -0,0 +1,50 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +My Fellow Nominations | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations fellow_my_nominations"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

My Fellow Nominations

+
+ +

+ Submit a new Fellow nomination +

+ + {% if nominations %} + + + + + + + + + + + {% for nom in nominations %} + + + + + + + {% endfor %} + +
NomineeRoundStatusSubmitted
{{ nom.nominee_name }}{{ nom.nomination_round }} + + {{ nom.get_status_display }} + + {{ nom.created|date:"N j, Y" }}
+ {% else %} +

You have not submitted any Fellow nominations yet.

+ {% endif %} +
+{% endblock content %} diff --git a/templates/nominations/fellow_nomination_confirm_delete.html b/templates/nominations/fellow_nomination_confirm_delete.html new file mode 100644 index 000000000..c75e83c15 --- /dev/null +++ b/templates/nominations/fellow_nomination_confirm_delete.html @@ -0,0 +1,38 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +Delete Fellow Nomination | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations fellow_nomination_confirm_delete"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

Delete Fellow Nomination

+
+ +
+

Are you sure you want to delete this nomination? This action cannot be undone.

+
+ +
+
    +
  • Nominee: {{ object.nominee_name }}
  • +
  • Nominator: {{ object.nominator.get_full_name }}
  • +
  • Round: {{ object.nomination_round }}
  • +
+
+ +
+ {% csrf_token %} +
+ +
+
+ +

← Cancel

+
+{% endblock content %} diff --git a/templates/nominations/fellow_nomination_dashboard.html b/templates/nominations/fellow_nomination_dashboard.html new file mode 100644 index 000000000..5ad7fb595 --- /dev/null +++ b/templates/nominations/fellow_nomination_dashboard.html @@ -0,0 +1,87 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +Fellow Nominations — WG Dashboard | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations fellow_nomination_dashboard"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

Fellow Nominations — WG Dashboard

+
+ + {% if current_round %} +
+

Current Round: {{ current_round }}

+
    +
  • Total nominations: {{ total_nominations }}
  • +
  • Pending: {{ pending_count }}
  • +
  • Under review: {{ under_review_count }}
  • +
  • Accepted: {{ accepted_count }}
  • +
  • Not accepted: {{ not_accepted_count }}
  • +
+
+ {% else %} +
+

No nomination round is currently open.

+
+ {% endif %} + + {% if needs_votes_count %} +
+

Attention

+

{{ needs_votes_count }} nomination{{ needs_votes_count|pluralize }} waiting for your vote:

+
    + {% for nom in needs_votes_nominations %} +
  • + {{ nom.nominee_name }} + {{ nom.get_status_display }} + — {{ nom.nomination_round }} +
  • + {% endfor %} +
+
+ {% endif %} + + + + {% if recent_rounds %} +
+

Recent Rounds

+ + + + + + + + + {% for round in recent_rounds %} + + + + + {% endfor %} + +
RoundStatus
{{ round }} + {% if round.is_open %} + Open + {% else %} + Closed + {% endif %} +
+
+ {% endif %} +
+{% endblock content %} diff --git a/templates/nominations/fellow_nomination_detail.html b/templates/nominations/fellow_nomination_detail.html new file mode 100644 index 000000000..ff15e59fe --- /dev/null +++ b/templates/nominations/fellow_nomination_detail.html @@ -0,0 +1,108 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +Fellow Nomination: {{ nomination.nominee_name }} | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations fellow_nomination_detail"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

Fellow Nomination: {{ nomination.nominee_name }}

+
+ + {% if nomination.nominee_is_fellow_at_submission %} +
+

Note: {{ nomination.nominee_name }} was already a PSF Fellow at the time of this nomination.

+
+ {% endif %} + +
+
    +
  • Nominee: {{ nomination.nominee_name }}
  • +
  • Nominated by: {{ nomination.nominator.get_full_name|default:nomination.nominator.username }}
  • +
  • Round: {{ nomination.nomination_round }}
  • +
  • Status: + + {{ nomination.get_status_display }} + +
  • +
  • Submitted: {{ nomination.created|date:"N j, Y" }}
  • + {% if nomination.expiry_round %} +
  • Active until: {{ nomination.expiry_round }}
  • + {% endif %} +
+
+ +
+

Nomination Statement

+ {{ nomination.nomination_statement.rendered|safe }} +
+ + {% if is_wg_member %} +
+

WG Review

+ +

Vote Summary

+ {% if yes_count or no_count or abstain_count %} +
    +
  • Yes: {{ yes_count }}
  • +
  • No: {{ no_count }}
  • +
  • Abstain: {{ abstain_count }}
  • +
+ {% if vote_result %} +

Threshold met (50%+1)

+ {% elif vote_result == False %} +

Threshold not met

+ {% endif %} + {% else %} +

No votes cast yet.

+ {% endif %} + +

Individual Votes

+ {% if votes %} + + + + + + + + + + {% for vote in votes %} + + + + + + {% endfor %} + +
VoterVoteComment
{{ vote.voter.get_full_name }}{{ vote.get_vote_display }}{{ vote.comment|default:"" }}
+ {% else %} +

No votes have been cast.

+ {% endif %} + +

+ Cast Vote + | Update Status + | Edit Nomination +

+
+ {% endif %} + +

+ {% if is_wg_member %} + ← Back to Dashboard + | Review Nominations + {% endif %} + {% if is_wg_member and request.user == nomination.nominator %}| {% endif %} + {% if request.user == nomination.nominator %} + ← My Nominations + {% endif %} +

+
+{% endblock content %} diff --git a/templates/nominations/fellow_nomination_form.html b/templates/nominations/fellow_nomination_form.html new file mode 100644 index 000000000..eb9f27cef --- /dev/null +++ b/templates/nominations/fellow_nomination_form.html @@ -0,0 +1,43 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +Nominate a PSF Fellow | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations fellow_nomination_form"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+ +
+

Nominate a PSF Fellow

+
+ + {% if nomination_round %} +

+ You are submitting a nomination for the {{ nomination_round }} round. + Nominations are open until {{ nomination_round.nominations_cutoff|date:"N j, Y" }}. +

+

+ The PSF Fellow Work Group reviews nominations quarterly. + Nominees who are not accepted in one round remain active for consideration + for up to one year (4 quarters). +

+ +
+ {% csrf_token %} + + {{ form.as_table }} +
+
+
+ +
+
+ {% else %} +

There is no open Fellow nomination round at this time. Please check back later.

+ {% endif %} +
+{% endblock content %} diff --git a/templates/nominations/fellow_nomination_manage_form.html b/templates/nominations/fellow_nomination_manage_form.html new file mode 100644 index 000000000..5478893d8 --- /dev/null +++ b/templates/nominations/fellow_nomination_manage_form.html @@ -0,0 +1,41 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +Edit Nomination: {{ object.nominee_name }} | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations fellow_nomination_manage_form"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

Edit Nomination: {{ object.nominee_name }}

+
+ +
+
    +
  • Nominator: {{ object.nominator.get_full_name }}
  • +
  • Round: {{ object.nomination_round }}
  • +
  • Created: {{ object.created|date:"N j, Y" }}
  • + {% if object.expiry_round %} +
  • Expiry round: {{ object.expiry_round }}
  • + {% endif %} +
+
+ +
+ {% csrf_token %} + + {{ form.as_table }} +
+
+
+ +
+
+ +

← Cancel

+
+{% endblock content %} diff --git a/templates/nominations/fellow_nomination_review.html b/templates/nominations/fellow_nomination_review.html new file mode 100644 index 000000000..82ca753c8 --- /dev/null +++ b/templates/nominations/fellow_nomination_review.html @@ -0,0 +1,78 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +Review Fellow Nominations | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations fellow_nomination_review"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

Review Fellow Nominations

+
+ +
+
+ + + + + +
+
+ + {% if nominations %} + + + + + + + + + + + + + + + {% for nom in nominations %} + + + + + + + + + + + {% endfor %} + +
NomineeNominatorRoundStatusYesNoAbstainActions
{{ nom.nominee_name }}{{ nom.nominator.get_full_name }}{{ nom.nomination_round }} + + {{ nom.get_status_display }} + + {{ nom.yes_count|default:"0" }}{{ nom.no_count|default:"0" }}{{ nom.abstain_count|default:"0" }} + Detail + | Status + | Vote +
+ {% else %} +

No nominations found matching the selected filters.

+ {% endif %} + +

← Back to Dashboard

+
+{% endblock content %} diff --git a/templates/nominations/fellow_nomination_status_form.html b/templates/nominations/fellow_nomination_status_form.html new file mode 100644 index 000000000..07c5e693e --- /dev/null +++ b/templates/nominations/fellow_nomination_status_form.html @@ -0,0 +1,39 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +Update Status: {{ object.nominee_name }} | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations fellow_nomination_status_form"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

Update Status: {{ object.nominee_name }}

+
+ +
+

+ Current status: + + {{ object.get_status_display }} + +

+
+ +
+ {% csrf_token %} + + {{ form.as_table }} +
+
+
+ +
+
+ +

← Cancel

+
+{% endblock content %} diff --git a/templates/nominations/fellow_nomination_vote_form.html b/templates/nominations/fellow_nomination_vote_form.html new file mode 100644 index 000000000..9a49bb62f --- /dev/null +++ b/templates/nominations/fellow_nomination_vote_form.html @@ -0,0 +1,46 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +Vote on Nomination: {{ nomination.nominee_name }} | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations fellow_nomination_vote_form"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

Vote on Nomination: {{ nomination.nominee_name }}

+
+ +
+

Nomination Summary

+
    +
  • Nominee: {{ nomination.nominee_name }}
  • +
  • Nominated by: {{ nomination.nominator.get_full_name }}
  • +
  • Round: {{ nomination.nomination_round }}
  • +
+ +

Statement

+ {{ nomination.nomination_statement.rendered|safe }} +
+ +
+

Per the WG Charter, members with a conflict of interest should recuse themselves from voting.

+
+ +
+ {% csrf_token %} + + {{ form.as_table }} +
+
+
+ +
+
+ +

← Cancel

+
+{% endblock content %} diff --git a/templates/nominations/fellow_round_form.html b/templates/nominations/fellow_round_form.html new file mode 100644 index 000000000..9ea0d34a8 --- /dev/null +++ b/templates/nominations/fellow_round_form.html @@ -0,0 +1,35 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +{% if object %}Edit Nomination Round: {{ object }}{% else %}Create Nomination Round{% endif %} | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations fellow_round_form"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

+ {% if object %}Edit Nomination Round: {{ object }}{% else %}Create Nomination Round{% endif %} +

+
+ +
+ {% csrf_token %} + + {{ form.as_table }} +
+

Leave date fields blank to auto-populate from year and quarter selection.

+
+
+ +
+
+ +

← Cancel

+
+{% endblock content %} diff --git a/templates/nominations/fellow_round_list.html b/templates/nominations/fellow_round_list.html new file mode 100644 index 000000000..6222d0bac --- /dev/null +++ b/templates/nominations/fellow_round_list.html @@ -0,0 +1,67 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +Manage Nomination Rounds | {{ SITE_INFO.site_name }} +{% endblock %} + +{% block body_attributes %}class="nominations fellow_round_list"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

Manage Nomination Rounds

+
+ +

Create New Round

+ + {% if rounds %} + + + + + + + + + + + + + {% for round in rounds %} + + + + + + + + + {% endfor %} + +
RoundQuarter DatesCutoff DateStatusNominationsActions
{{ round }}{{ round.quarter_start|date:"N j, Y" }} – {{ round.quarter_end|date:"N j, Y" }}{{ round.nominations_cutoff|date:"N j, Y" }} + {% if round.is_open %} + Open + {% else %} + Closed + {% endif %} + {{ round.nomination_count }} + Edit + | +
+ {% csrf_token %} + {% if round.is_open %} + + {% else %} + + {% endif %} +
+
+ {% else %} +

No nomination rounds have been created yet.

+ {% endif %} + +

← Back to Dashboard

+
+{% endblock content %} diff --git a/templates/nominations/fellows_roster.html b/templates/nominations/fellows_roster.html new file mode 100644 index 000000000..9b1b15e9d --- /dev/null +++ b/templates/nominations/fellows_roster.html @@ -0,0 +1,390 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +Fellows of the Python Software Foundation +{% endblock %} + +{% block body_attributes %}class="nominations fellows_roster"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+

PSF Fellows

+
+ + {% if not active_fellows and not emeritus_fellows and not deceased_fellows %} +

No PSF Fellows found.

+ {% else %} +
+ + +
+ + + + +
+ + Showing {{ total_count }} of {{ total_count }} fellows +
+ + + +

No fellows match your filters.

+ {% endif %} +
+{% endblock content %} + +{% block extra_js %} + +{% endblock extra_js %}