From 784aa21b6106eb9ae93d75c9feac7d2b4a62f0ad Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:43:38 -0600 Subject: [PATCH 01/40] ai: extend nominations to allow for fellows --- docker-compose.yml | 9 + nominations/admin.py | 39 +- nominations/factories.py | 338 +++++++++++++ nominations/forms.py | 195 +++++++- nominations/management/__init__.py | 0 nominations/management/commands/__init__.py | 0 .../commands/backfill_fellow_memberships.py | 201 ++++++++ .../close_expired_fellow_nominations.py | 20 + nominations/managers.py | 22 + .../migrations/0003_fellow_nominations.py | 75 +++ nominations/models.py | 199 +++++++- nominations/notifications.py | 106 ++++ nominations/tasks.py | 8 + nominations/tests/__init__.py | 0 nominations/tests/factories.py | 43 ++ nominations/tests/test_forms.py | 65 +++ nominations/tests/test_management_views.py | 411 ++++++++++++++++ nominations/tests/test_models.py | 347 +++++++++++++ nominations/tests/test_review_views.py | 285 +++++++++++ nominations/tests/test_roster_views.py | 141 ++++++ nominations/tests/test_views.py | 179 +++++++ nominations/urls.py | 41 ++ nominations/views.py | 460 +++++++++++++++++- pydotorg/settings/base.py | 11 + pydotorg/settings/local.py | 4 +- pydotorg/urls.py | 3 + .../email/fellow_nomination_accepted.html | 163 +++++++ .../email/fellow_nomination_accepted.txt | 17 + .../fellow_nomination_accepted_subject.txt | 1 + .../email/fellow_nomination_not_accepted.html | 162 ++++++ .../email/fellow_nomination_not_accepted.txt | 19 + ...fellow_nomination_not_accepted_subject.txt | 1 + .../email/fellow_nomination_submitted.html | 150 ++++++ .../email/fellow_nomination_submitted.txt | 20 + .../fellow_nomination_submitted_subject.txt | 1 + .../email/fellow_nomination_submitted_wg.html | 171 +++++++ .../email/fellow_nomination_submitted_wg.txt | 14 + ...fellow_nomination_submitted_wg_subject.txt | 1 + .../nominations/fellow_my_nominations.html | 50 ++ .../fellow_nomination_confirm_delete.html | 38 ++ .../fellow_nomination_dashboard.html | 87 ++++ .../nominations/fellow_nomination_detail.html | 108 ++++ .../nominations/fellow_nomination_form.html | 43 ++ .../fellow_nomination_manage_form.html | 41 ++ .../nominations/fellow_nomination_review.html | 78 +++ .../fellow_nomination_status_form.html | 39 ++ .../fellow_nomination_vote_form.html | 46 ++ templates/nominations/fellow_round_form.html | 35 ++ templates/nominations/fellow_round_list.html | 67 +++ templates/nominations/fellows_roster.html | 46 ++ 50 files changed, 4588 insertions(+), 12 deletions(-) create mode 100644 nominations/factories.py create mode 100644 nominations/management/__init__.py create mode 100644 nominations/management/commands/__init__.py create mode 100644 nominations/management/commands/backfill_fellow_memberships.py create mode 100644 nominations/management/commands/close_expired_fellow_nominations.py create mode 100644 nominations/managers.py create mode 100644 nominations/migrations/0003_fellow_nominations.py create mode 100644 nominations/notifications.py create mode 100644 nominations/tasks.py create mode 100644 nominations/tests/__init__.py create mode 100644 nominations/tests/factories.py create mode 100644 nominations/tests/test_forms.py create mode 100644 nominations/tests/test_management_views.py create mode 100644 nominations/tests/test_models.py create mode 100644 nominations/tests/test_review_views.py create mode 100644 nominations/tests/test_roster_views.py create mode 100644 nominations/tests/test_views.py create mode 100644 templates/nominations/email/fellow_nomination_accepted.html create mode 100644 templates/nominations/email/fellow_nomination_accepted.txt create mode 100644 templates/nominations/email/fellow_nomination_accepted_subject.txt create mode 100644 templates/nominations/email/fellow_nomination_not_accepted.html create mode 100644 templates/nominations/email/fellow_nomination_not_accepted.txt create mode 100644 templates/nominations/email/fellow_nomination_not_accepted_subject.txt create mode 100644 templates/nominations/email/fellow_nomination_submitted.html create mode 100644 templates/nominations/email/fellow_nomination_submitted.txt create mode 100644 templates/nominations/email/fellow_nomination_submitted_subject.txt create mode 100644 templates/nominations/email/fellow_nomination_submitted_wg.html create mode 100644 templates/nominations/email/fellow_nomination_submitted_wg.txt create mode 100644 templates/nominations/email/fellow_nomination_submitted_wg_subject.txt create mode 100644 templates/nominations/fellow_my_nominations.html create mode 100644 templates/nominations/fellow_nomination_confirm_delete.html create mode 100644 templates/nominations/fellow_nomination_dashboard.html create mode 100644 templates/nominations/fellow_nomination_detail.html create mode 100644 templates/nominations/fellow_nomination_form.html create mode 100644 templates/nominations/fellow_nomination_manage_form.html create mode 100644 templates/nominations/fellow_nomination_review.html create mode 100644 templates/nominations/fellow_nomination_status_form.html create mode 100644 templates/nominations/fellow_nomination_vote_form.html create mode 100644 templates/nominations/fellow_round_form.html create mode 100644 templates/nominations/fellow_round_list.html create mode 100644 templates/nominations/fellows_roster.html 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/nominations/admin.py b/nominations/admin.py index 07e516488..9f474ee16 100644 --- a/nominations/admin.py +++ b/nominations/admin.py @@ -2,7 +2,14 @@ from django.db.models.functions import Lower -from nominations.models import Election, Nominee, Nomination +from nominations.models import ( + Election, + FellowNomination, + FellowNominationRound, + FellowNominationVote, + Nomination, + Nominee, +) @admin.register(Election) @@ -29,3 +36,33 @@ class NominationAdmin(admin.ModelAdmin): def get_ordering(self, request): return ['election', Lower('nominee__user__last_name')] + + +@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/factories.py b/nominations/factories.py new file mode 100644 index 000000000..777501ac5 --- /dev/null +++ b/nominations/factories.py @@ -0,0 +1,338 @@ +import datetime + +from django.contrib.auth.models import Group + +from users.factories import UserFactory +from users.models import Membership + +from .models import ( + FellowNomination, + FellowNominationRound, + FellowNominationVote, +) + + +def _create_user(username, first_name, last_name, email=None, is_staff=False): + """Create a user with a specific username (idempotent via get_or_create pattern).""" + from users.models import User + + 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() + return user + + +def _create_round(year, quarter, is_open=True): + """Create a FellowNominationRound with standard dates for the given quarter.""" + 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, _ = 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, + }, + ) + return obj + + +def _create_nomination(nominator, nominee_name, nominee_email, nomination_round, + status="pending", expiry_round=None, nominee_user=None, + nominee_is_fellow_at_submission=False): + """Create a FellowNomination.""" + 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 _create_fellow_membership(user, city="", country=""): + """Create a Fellow Membership for a user if one doesn't already exist.""" + try: + return user.membership + except Membership.DoesNotExist: + return Membership.objects.create( + creator=user, + membership_type=Membership.FELLOW, + legal_name=f"{user.first_name} {user.last_name}", + preferred_name=user.first_name, + email_address=user.email, + city=city, + country=country, + ) + + +def initial_data(): + # --- Phase 1: Groups, users, rounds, nominations --- + + wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") + + # WG members (Phase 1 creates 1, Phase 2 adds 3 more = 4 total) + wg_member1 = _create_user("wg_alice", "Alice", "WGMember", "alice.wg@python.org") + wg_member2 = _create_user("wg_bob", "Bob", "Reviewer", "bob.wg@python.org") + wg_member3 = _create_user("wg_carol", "Carol", "Evaluator", "carol.wg@python.org") + wg_member4 = _create_user("wg_dave", "Dave", "Assessor", "dave.wg@python.org") + for member in [wg_member1, wg_member2, wg_member3, wg_member4]: + member.groups.add(wg_group) + + # Staff user (not in WG group) for testing staff fallback access + staff_user = _create_user("staff_admin", "Staff", "Admin", is_staff=True) + + # Regular nominators + nominator1 = _create_user("nominator1", "Nominator", "One", "nominator1@example.com") + nominator2 = _create_user("nominator2", "Nominator", "Two", "nominator2@example.com") + + # Rounds + past_round = _create_round(2025, 3, is_open=False) # 2025-Q3 (closed) + current_round = _create_round(2026, 1, is_open=True) # 2026-Q1 (open, current) + future_round = _create_round(2026, 2, is_open=False) # 2026-Q2 (future, empty) + expiry_round = _create_round(2026, 4, is_open=False) # 2026-Q4 (for expiry targets) + old_expiry = _create_round(2025, 2, is_open=False) # 2025-Q2 (past, for expired nom) + + # --- Past round nominations (2025-Q3) --- + past_accepted1 = _create_nomination( + nominator1, "Past Accepted One", "past1@example.com", + past_round, status="accepted", + ) + past_accepted2 = _create_nomination( + nominator2, "Past Accepted Two", "past2@example.com", + past_round, status="accepted", + ) + past_not_accepted = _create_nomination( + nominator1, "Past Not Accepted", "past_na@example.com", + past_round, status="not_accepted", + ) + + # --- Current round nominations (2026-Q1) --- + + # 3 pending nominations + pending1 = _create_nomination( + nominator1, "Pending Person One", "pending1@example.com", + current_round, status="pending", expiry_round=expiry_round, + ) + pending2 = _create_nomination( + nominator2, "Pending Person Two", "pending2@example.com", + current_round, status="pending", expiry_round=expiry_round, + ) + pending3 = _create_nomination( + nominator1, "Pending Person Three", "pending3@example.com", + current_round, status="pending", expiry_round=expiry_round, + ) + + # 2 under_review nominations (will have votes added in Phase 2 section) + under_review_majority_yes = _create_nomination( + nominator1, "Review Majority Yes", "review_yes@example.com", + current_round, status="under_review", expiry_round=expiry_round, + ) + under_review_majority_no = _create_nomination( + nominator2, "Review Majority No", "review_no@example.com", + current_round, status="under_review", expiry_round=expiry_round, + ) + + # Additional under_review for vote scenarios + under_review_tie = _create_nomination( + nominator1, "Review Tie Vote", "review_tie@example.com", + current_round, status="under_review", expiry_round=expiry_round, + ) + under_review_abstains = _create_nomination( + nominator2, "Review All Abstain", "review_abstain@example.com", + current_round, status="under_review", expiry_round=expiry_round, + ) + under_review_one_vote = _create_nomination( + nominator1, "Review One Vote", "review_onevote@example.com", + current_round, status="under_review", expiry_round=expiry_round, + ) + + # 1 accepted in current round + current_accepted = _create_nomination( + nominator2, "Current Accepted", "current_accepted@example.com", + current_round, status="accepted", + ) + + # 1 nomination where nominee is already a Fellow + fellow_nominee_user = _create_user("already_fellow", "Already", "Fellow", "already_fellow@example.com") + _create_fellow_membership(fellow_nominee_user, city="San Francisco", country="USA") + already_fellow_nom = _create_nomination( + nominator1, "Already Fellow", "already_fellow@example.com", + current_round, status="pending", expiry_round=expiry_round, + nominee_user=fellow_nominee_user, nominee_is_fellow_at_submission=True, + ) + + # 1 expired nomination (expiry_round in the past, still pending) + expired_nom = _create_nomination( + nominator2, "Expired Pending", "expired@example.com", + past_round, status="pending", expiry_round=old_expiry, + ) + + # --- Phase 2: Votes on under_review nominations --- + + # Majority yes (3 yes, 1 no) — threshold met + FellowNominationVote.objects.get_or_create( + nomination=under_review_majority_yes, voter=wg_member1, + defaults={"vote": "yes", "comment": "Strong contributor."}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_majority_yes, voter=wg_member2, + defaults={"vote": "yes", "comment": "Agree."}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_majority_yes, voter=wg_member3, + defaults={"vote": "yes", "comment": "Excellent candidate."}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_majority_yes, voter=wg_member4, + defaults={"vote": "no", "comment": "Need more info."}) + + # Majority no (1 yes, 2 no, 1 abstain) — threshold not met + FellowNominationVote.objects.get_or_create( + nomination=under_review_majority_no, voter=wg_member1, + defaults={"vote": "yes", "comment": "Good work."}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_majority_no, voter=wg_member2, + defaults={"vote": "no", "comment": "Insufficient contributions."}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_majority_no, voter=wg_member3, + defaults={"vote": "no", "comment": "Not yet."}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_majority_no, voter=wg_member4, + defaults={"vote": "abstain", "comment": "Conflict of interest."}) + + # Tie (2 yes, 2 no) — threshold not met + FellowNominationVote.objects.get_or_create( + nomination=under_review_tie, voter=wg_member1, + defaults={"vote": "yes"}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_tie, voter=wg_member2, + defaults={"vote": "yes"}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_tie, voter=wg_member3, + defaults={"vote": "no"}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_tie, voter=wg_member4, + defaults={"vote": "no"}) + + # All abstains — no result + FellowNominationVote.objects.get_or_create( + nomination=under_review_abstains, voter=wg_member1, + defaults={"vote": "abstain"}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_abstains, voter=wg_member2, + defaults={"vote": "abstain"}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_abstains, voter=wg_member3, + defaults={"vote": "abstain"}) + FellowNominationVote.objects.get_or_create( + nomination=under_review_abstains, voter=wg_member4, + defaults={"vote": "abstain"}) + + # One vote cast — voting in progress + FellowNominationVote.objects.get_or_create( + nomination=under_review_one_vote, voter=wg_member1, + defaults={"vote": "yes", "comment": "Looks promising."}) + + # --- Phase 3: Fellow Membership records for public roster --- + + # Fellows with full profiles (some went through nomination flow) + fellow1 = _create_user("guido_van_rossum", "Guido", "van Rossum", "guido@python.org") + _create_fellow_membership(fellow1, city="Belmont", country="USA") + + fellow2 = _create_user("carol_willing", "Carol", "Willing", "carol@python.org") + _create_fellow_membership(fellow2, city="San Diego", country="USA") + + fellow3 = _create_user("mariatta_wijaya", "Mariatta", "Wijaya", "mariatta@python.org") + _create_fellow_membership(fellow3, city="Vancouver", country="Canada") + + fellow4 = _create_user("naomi_ceder", "Naomi", "Ceder", "naomi@python.org") + _create_fellow_membership(fellow4, city="Houston", country="USA") + + fellow5 = _create_user("victor_stinner", "Victor", "Stinner", "victor@python.org") + _create_fellow_membership(fellow5, city="", country="France") + + # Fellows without full location + fellow6 = _create_user("brett_cannon", "Brett", "Cannon", "brett@python.org") + _create_fellow_membership(fellow6) # No city/country + + fellow7 = _create_user("barry_warsaw", "Barry", "Warsaw", "barry@python.org") + _create_fellow_membership(fellow7, city="Boston", country="") # City only + + # already_fellow_nom user already has a Fellow membership from above + + # Non-Fellow memberships (should NOT appear on roster) + basic_user = _create_user("basic_member", "Basic", "Member", "basic@example.com") + try: + basic_user.membership + except Membership.DoesNotExist: + Membership.objects.create( + creator=basic_user, + membership_type=Membership.BASIC, + legal_name="Basic Member", + preferred_name="Basic", + email_address=basic_user.email, + ) + + supporting_user = _create_user("supporting_member", "Supporting", "Member", "supporting@example.com") + try: + supporting_user.membership + except Membership.DoesNotExist: + Membership.objects.create( + creator=supporting_user, + membership_type=Membership.SUPPORTING, + legal_name="Supporting Member", + preferred_name="Supporting", + email_address=supporting_user.email, + ) + + contributing_user = _create_user("contributing_member", "Contributing", "Member", "contributing@example.com") + try: + contributing_user.membership + except Membership.DoesNotExist: + Membership.objects.create( + creator=contributing_user, + membership_type=Membership.CONTRIBUTING, + legal_name="Contributing Member", + preferred_name="Contributing", + email_address=contributing_user.email, + ) + + return { + "groups": [wg_group], + "wg_members": [wg_member1, wg_member2, wg_member3, wg_member4], + "staff": [staff_user], + "nominators": [nominator1, nominator2], + "rounds": [past_round, current_round, future_round, expiry_round], + "nominations": [ + past_accepted1, past_accepted2, past_not_accepted, + pending1, pending2, pending3, + under_review_majority_yes, under_review_majority_no, + under_review_tie, under_review_abstains, under_review_one_vote, + current_accepted, already_fellow_nom, expired_nom, + ], + "fellows": [fellow1, fellow2, fellow3, fellow4, fellow5, fellow6, fellow7, fellow_nominee_user], + } diff --git a/nominations/forms.py b/nominations/forms.py index 4a221fc2f..3e7d2ba35 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 .models import ( + FellowNomination, + FellowNominationRound, + FellowNominationVote, + Nomination, +) class NominationForm(forms.ModelForm): @@ -62,3 +69,189 @@ 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? Markdown supported.", + } + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request", None) + super().__init__(*args, **kwargs) + + 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." + ) + 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.Q1: { + "quarter_start": (1, 1), + "quarter_end": (3, 31), + "nominations_cutoff": (2, 20), + "review_end": (3, 20), + }, + FellowNominationRound.Q2: { + "quarter_start": (4, 1), + "quarter_end": (6, 30), + "nominations_cutoff": (5, 20), + "review_end": (6, 20), + }, + FellowNominationRound.Q3: { + "quarter_start": (7, 1), + "quarter_end": (9, 30), + "nominations_cutoff": (8, 20), + "review_end": (9, 20), + }, + FellowNominationRound.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) + # Default year to current year on create (not edit) + if not self.instance.pk and not self.initial.get("year"): + 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/backfill_fellow_memberships.py b/nominations/management/commands/backfill_fellow_memberships.py new file mode 100644 index 000000000..0b0a7dbbc --- /dev/null +++ b/nominations/management/commands/backfill_fellow_memberships.py @@ -0,0 +1,201 @@ +import csv + +from django.core.management.base import BaseCommand +from django.db import transaction + +from users.models import Membership, User + + +class Command(BaseCommand): + help = ( + "One-time backfill script to create Membership records (type=FELLOW) " + "from a CSV data source." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--csv", + required=True, + help="Path to the CSV file with columns: email, first_name, last_name, city, country", + ) + parser.add_argument( + "--dry-run", + action="store_true", + default=False, + help="Print what would be done without making changes.", + ) + + def handle(self, *args, **options): + csv_path = options["csv"] + dry_run = options["dry_run"] + + if dry_run: + self.stdout.write(self.style.WARNING("DRY RUN mode enabled. No changes will be made.\n")) + + rows = self._read_csv(csv_path) + if rows is None: + return + + if dry_run: + self._process_rows(rows, dry_run=True) + else: + with transaction.atomic(): + self._process_rows(rows, dry_run=False) + + def _read_csv(self, csv_path): + """Read and return rows from the CSV file, or None on error.""" + try: + with open(csv_path, newline="", encoding="utf-8") as f: + reader = csv.DictReader(f) + return list(reader) + except FileNotFoundError: + self.stderr.write(self.style.ERROR(f"CSV file not found: {csv_path}")) + return None + except Exception as e: + self.stderr.write(self.style.ERROR(f"Error reading CSV: {e}")) + return None + + def _process_rows(self, rows, *, dry_run): + """Process all CSV rows, creating users and memberships as needed.""" + created_count = 0 + skipped_count = 0 + error_count = 0 + + for line_num, row in enumerate(rows, start=2): # start=2 because line 1 is the header + email = (row.get("email") or "").strip() + if not email: + self.stderr.write( + self.style.WARNING(f"Line {line_num}: Skipping row with missing/empty email.") + ) + skipped_count += 1 + continue + + first_name = (row.get("first_name") or "").strip() + last_name = (row.get("last_name") or "").strip() + city = (row.get("city") or "").strip() + country = (row.get("country") or "").strip() + legal_name = f"{first_name} {last_name}".strip() + + try: + result = self._process_single_row( + email=email, + first_name=first_name, + last_name=last_name, + legal_name=legal_name, + city=city, + country=country, + dry_run=dry_run, + line_num=line_num, + ) + if result == "created": + created_count += 1 + elif result == "skipped": + skipped_count += 1 + except Exception as e: + self.stderr.write( + self.style.ERROR(f"Line {line_num}: Error processing {email}: {e}") + ) + error_count += 1 + + # Print summary + self.stdout.write("") + prefix = "[DRY RUN] " if dry_run else "" + self.stdout.write(self.style.SUCCESS(f"{prefix}Summary:")) + self.stdout.write(f" Created: {created_count}") + self.stdout.write(f" Skipped: {skipped_count}") + self.stdout.write(f" Errors: {error_count}") + + def _process_single_row(self, *, email, first_name, last_name, legal_name, city, country, dry_run, line_num): + """ + Process a single CSV row. Returns 'created' or 'skipped'. + Raises on unexpected errors. + """ + email_lower = email.lower() + + # Look up existing user (case-insensitive email match) + try: + user = User.objects.get(email__iexact=email_lower) + except User.DoesNotExist: + user = None + except User.MultipleObjectsReturned: + self.stderr.write( + self.style.WARNING( + f"Line {line_num}: Multiple users found for {email}. Skipping." + ) + ) + return "skipped" + + # Check for existing membership of any type + if user is not None: + try: + existing = user.membership + if existing is not None: + if existing.membership_type == Membership.FELLOW: + self.stdout.write( + f"Line {line_num}: {email} already has a Fellow membership. Skipping." + ) + else: + membership_label = existing.get_membership_type_display() + self.stderr.write( + self.style.WARNING( + f"Line {line_num}: {email} already has a '{membership_label}' " + f"membership. Skipping." + ) + ) + return "skipped" + except Membership.DoesNotExist: + pass # No membership yet, proceed to create one + + if dry_run: + action = "create user + " if user is None else "" + self.stdout.write( + f"[DRY RUN] Line {line_num}: Would {action}create Fellow membership " + f"for {email} ({legal_name})" + ) + return "created" + + # Create user if needed + if user is None: + username = self._generate_username(email_lower) + user = User.objects.create_user( + username=username, + email=email_lower, + first_name=first_name, + last_name=last_name, + ) + self.stdout.write(f"Line {line_num}: Created user '{username}' for {email}.") + + # Create the Fellow membership + Membership.objects.create( + creator=user, + membership_type=Membership.FELLOW, + legal_name=legal_name, + preferred_name=first_name, + email_address=email_lower, + city=city, + country=country, + ) + self.stdout.write( + self.style.SUCCESS( + f"Line {line_num}: Created Fellow membership for {email} ({legal_name})." + ) + ) + return "created" + + def _generate_username(self, email): + """ + Generate a unique username from the email address. + Uses the local part of the email, appending a numeric suffix if needed. + """ + base = email.split("@")[0] + # Sanitize: keep only alphanumeric, dots, hyphens, underscores + base = "".join(c for c in base if c.isalnum() or c in ".-_") + if not base: + base = "fellow" + + username = base + suffix = 1 + while User.objects.filter(username=username).exists(): + username = f"{base}{suffix}" + suffix += 1 + return username 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..730332224 --- /dev/null +++ b/nominations/management/commands/close_expired_fellow_nominations.py @@ -0,0 +1,20 @@ +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.count() + expired.update(status=FellowNomination.NOT_ACCEPTED) + self.stdout.write( + self.style.SUCCESS(f"Closed {count} expired Fellow nomination(s).") + ) diff --git a/nominations/managers.py b/nominations/managers.py new file mode 100644 index 000000000..e812ac2a1 --- /dev/null +++ b/nominations/managers.py @@ -0,0 +1,22 @@ +from django.db import models +from django.utils import timezone + + +class FellowNominationQuerySet(models.QuerySet): + def active(self): + """Exclude accepted/not_accepted, filter by expiry_round still in future.""" + return self.exclude( + status__in=["accepted", "not_accepted"] + ).filter( + expiry_round__quarter_end__gte=timezone.now().date() + ) + + 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..0e8dac1ca --- /dev/null +++ b/nominations/migrations/0003_fellow_nominations.py @@ -0,0 +1,75 @@ +# Generated by Django 4.2.28 on 2026-02-06 01:17 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import markupfield.fields + + +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)), + ('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/models.py b/nominations/models.py index f52a286be..2dfad8149 100644 --- a/nominations/models.py +++ b/nominations/models.py @@ -1,15 +1,20 @@ 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 users.models import User +from cms.models import ContentManageable +from users.models import Membership, User + +from .managers import FellowNominationQuerySet class Election(models.Model): @@ -271,3 +276,195 @@ def purge_nomination_pages(sender, instance, created, **kwargs): "nominations:nominees_list", kwargs={"election": instance.election.slug} ) ) + + +class FellowNominationRound(models.Model): + """Quarterly round for PSF Fellow Work Group consideration.""" + + Q1 = 1 + Q2 = 2 + Q3 = 3 + Q4 = 4 + QUARTER_CHOICES = ( + (Q1, "Q1 (Jan-Mar)"), + (Q2, "Q2 (Apr-Jun)"), + (Q3, "Q3 (Jul-Sep)"), + (Q4, "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.membership.membership_type == Membership.FELLOW + except Membership.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..1a17edcb4 --- /dev/null +++ b/nominations/tasks.py @@ -0,0 +1,8 @@ +from celery import shared_task +from django.core.management import call_command + + +@shared_task +def close_expired_fellow_nominations(): + """Close Fellow nominations that have passed their expiry round.""" + call_command("close_expired_fellow_nominations") 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..586b594f0 --- /dev/null +++ b/nominations/tests/factories.py @@ -0,0 +1,43 @@ +import datetime +import factory +from factory.django import DjangoModelFactory + +from users.models import User +from nominations.models import FellowNominationRound, FellowNomination + + +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..a3f374feb --- /dev/null +++ b/nominations/tests/test_forms.py @@ -0,0 +1,65 @@ +from django.test import TestCase, RequestFactory + +from users.models import User + +from nominations.forms import FellowNominationForm +from nominations.models import FellowNominationRound +from .factories import UserFactory, FellowNominationRoundFactory + + +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": "Great contributor to Python.", + "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..46a69484f --- /dev/null +++ b/nominations/tests/test_management_views.py @@ -0,0 +1,411 @@ +import datetime + +from django.test import TestCase, Client +from django.urls import reverse +from django.contrib.auth.models import Group + +from nominations.models import FellowNomination, FellowNominationRound +from .factories import ( + UserFactory, + FellowNominationRoundFactory, + FellowNominationFactory, +) + + +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..05929976d --- /dev/null +++ b/nominations/tests/test_models.py @@ -0,0 +1,347 @@ +import datetime +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from users.models import Membership + +from nominations.models import ( + FellowNominationRound, + FellowNomination, + FellowNominationVote, +) +from .factories import ( + UserFactory, + FellowNominationRoundFactory, + FellowNominationFactory, +) + + +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(Exception): + 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_membership(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() + Membership.objects.create( + creator=nominee_user, + membership_type=Membership.FELLOW, + legal_name="Test Fellow", + preferred_name="Test", + email_address=nominee_user.email, + ) + self.nomination.nominee_user = nominee_user + self.assertTrue(self.nomination.nominee_is_already_fellow) + + def test_nominee_is_already_fellow_false_basic_member(self): + nominee_user = UserFactory() + Membership.objects.create( + creator=nominee_user, + membership_type=Membership.BASIC, + legal_name="Test Basic", + preferred_name="Test", + email_address=nominee_user.email, + ) + self.nomination.nominee_user = nominee_user + self.assertFalse(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(Exception): + 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) + ) + nom = 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) + ) + nom = FellowNominationFactory( + nomination_round=self.round, + expiry_round=self.future_round, + status="pending", + ) + self.assertEqual(FellowNomination.objects.active().count(), 1) + + def test_for_round(self): + nom1 = 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), + ) + nom2 = 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..6ef46822d --- /dev/null +++ b/nominations/tests/test_review_views.py @@ -0,0 +1,285 @@ +import datetime +from unittest.mock import patch + +from django.test import TestCase, Client +from django.urls import reverse +from django.utils import timezone +from django.contrib.auth.models import Group + +from nominations.models import FellowNomination, FellowNominationVote +from .factories import ( + UserFactory, + FellowNominationRoundFactory, + FellowNominationFactory, +) + + +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..d8176d6a7 --- /dev/null +++ b/nominations/tests/test_roster_views.py @@ -0,0 +1,141 @@ +from django.test import TestCase, Client +from django.urls import reverse + +from users.models import Membership + +from .factories import UserFactory + + +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_works(self): + """The alternate URL /psf/fellows-roster/ should also work.""" + response = self.client.get(self.alt_url) + self.assertEqual(response.status_code, 200) + + def test_only_fellows_shown(self): + """Only members with membership_type=FELLOW should appear on the roster.""" + fellow_user = UserFactory(first_name="Alice", last_name="Fellow") + Membership.objects.create( + creator=fellow_user, + membership_type=Membership.FELLOW, + legal_name="Alice Fellow", + preferred_name="Alice", + email_address=fellow_user.email, + ) + basic_user = UserFactory(first_name="Bob", last_name="Basic") + Membership.objects.create( + creator=basic_user, + membership_type=Membership.BASIC, + legal_name="Bob Basic", + preferred_name="Bob", + email_address=basic_user.email, + ) + supporting_user = UserFactory(first_name="Carol", last_name="Supporter") + Membership.objects.create( + creator=supporting_user, + membership_type=Membership.SUPPORTING, + legal_name="Carol Supporter", + preferred_name="Carol", + email_address=supporting_user.email, + ) + response = self.client.get(self.url) + self.assertContains(response, "Alice Fellow") + self.assertNotContains(response, "Bob Basic") + self.assertNotContains(response, "Carol Supporter") + + def test_alphabetical_ordering(self): + """Fellows should be ordered by last name, then first name.""" + user_z = UserFactory(first_name="Zara", last_name="Zebra") + Membership.objects.create( + creator=user_z, + membership_type=Membership.FELLOW, + legal_name="Zara Zebra", + preferred_name="Zara", + email_address=user_z.email, + ) + user_a = UserFactory(first_name="Alice", last_name="Alpha") + Membership.objects.create( + creator=user_a, + membership_type=Membership.FELLOW, + legal_name="Alice Alpha", + preferred_name="Alice", + email_address=user_a.email, + ) + user_m = UserFactory(first_name="Mike", last_name="Middle") + Membership.objects.create( + creator=user_m, + membership_type=Membership.FELLOW, + legal_name="Mike Middle", + preferred_name="Mike", + email_address=user_m.email, + ) + 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): + user = UserFactory(first_name=f"Fellow{i}", last_name=f"User{i}") + Membership.objects.create( + creator=user, + membership_type=Membership.FELLOW, + legal_name=f"Fellow{i} User{i}", + preferred_name=f"Fellow{i}", + email_address=user.email, + ) + response = self.client.get(self.url) + self.assertEqual(response.context["total_count"], 3) + self.assertContains(response, "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_fellow_with_location(self): + """Fellow with city and country should display location info.""" + user = UserFactory(first_name="Located", last_name="Fellow") + Membership.objects.create( + creator=user, + membership_type=Membership.FELLOW, + legal_name="Located Fellow", + preferred_name="Located", + email_address=user.email, + city="Portland", + country="USA", + ) + response = self.client.get(self.url) + self.assertContains(response, "Portland") + self.assertContains(response, "USA") + + def test_fellow_without_location(self): + """Fellow without city/country should still render without errors.""" + user = UserFactory(first_name="NoLoc", last_name="Fellow") + Membership.objects.create( + creator=user, + membership_type=Membership.FELLOW, + legal_name="NoLoc Fellow", + preferred_name="NoLoc", + email_address=user.email, + city="", + country="", + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "NoLoc Fellow") diff --git a/nominations/tests/test_views.py b/nominations/tests/test_views.py new file mode 100644 index 000000000..f43827edf --- /dev/null +++ b/nominations/tests/test_views.py @@ -0,0 +1,179 @@ +import datetime +from unittest.mock import patch + +from django.test import TestCase, Client +from django.urls import reverse +from django.utils import timezone +from django.contrib.auth.models import Group + +from users.models import Membership + +from nominations.models import FellowNomination, FellowNominationRound +from .factories import ( + UserFactory, + FellowNominationRoundFactory, + FellowNominationFactory, +) + + +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": "Great contributions.", + "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") + Membership.objects.create( + creator=fellow_user, + membership_type=Membership.FELLOW, + legal_name="Fellow User", + preferred_name="Fellow", + email_address="fellow@example.com", + ) + data = { + "nominee_name": "Fellow User", + "nominee_email": "fellow@example.com", + "nomination_statement": "Already a fellow.", + "nomination_statement_markup_type": "markdown", + } + response = 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.assertTrue(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..6ba200f27 100644 --- a/nominations/urls.py +++ b/nominations/urls.py @@ -23,4 +23,45 @@ 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..e0174546a 100644 --- a/nominations/views.py +++ b/nominations/views.py @@ -1,14 +1,48 @@ 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 users.models import Membership + +from .forms import ( + FellowNominationForm, + FellowNominationManageForm, + FellowNominationRoundForm, + FellowNominationStatusForm, + FellowNominationVoteForm, + NominationAcceptForm, + NominationCreateForm, + NominationForm, +) +from .models import ( + Election, + FellowNomination, + FellowNominationRound, + FellowNominationVote, + Nomination, + Nominee, +) +from .notifications import ( + FellowNominationAcceptedNotification, + FellowNominationNotAcceptedNotification, + FellowNominationSubmittedToNominator, + FellowNominationSubmittedToWG, +) class ElectionsList(ListView): @@ -196,3 +230,413 @@ 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: + 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: + raise Http404("No open Fellow nomination round at this time.") + 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.membership.membership_type == Membership.FELLOW: + 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 Membership.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() + 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 Membership.objects.filter( + membership_type=Membership.FELLOW, + ).select_related("creator").order_by( + "creator__last_name", "creator__first_name" + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["total_count"] = self.get_queryset().count() + return context \ No newline at end of file diff --git a/pydotorg/settings/base.py b/pydotorg/settings/base.py index 0fac91eb1..c73d10927 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,11 @@ ) PYPI_SPONSORS_CSV = os.path.join(BASE, "data", "pypi-sponsors.csv") +# Fellow Nominations +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 a8a4fdb09..175ea1c1f 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..3c151d00f 100644 --- a/pydotorg/urls.py +++ b/pydotorg/urls.py @@ -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/', FellowsRoster.as_view(), 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..26425c422 --- /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..91cd67b3b --- /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 }}
  • +
  • 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 / Update 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..7dd608efd --- /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..73c863f31 --- /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..0e7320d14 --- /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..a6d41d74f --- /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..bc4ba1bb1 --- /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..f03d72218 --- /dev/null +++ b/templates/nominations/fellows_roster.html @@ -0,0 +1,46 @@ +{% extends "psf/default.html" %} + +{% block page_title %} +PSF Fellows | Python Software Foundation +{% endblock %} + +{% block body_attributes %}class="nominations fellows_roster"{% endblock %} +{% block left_sidebar %}{% endblock %} +{% block content_attributes %}{% endblock %} + +{% block content %} +
+
+

PSF Fellows

+
+ +

There are currently {{ total_count }} PSF Fellows.

+ + {% if fellows %} +
    + {% for fellow in fellows %} +
  • + + {% if fellow.creator %} + {{ fellow.creator.get_full_name|default:fellow.creator.username }} + {% elif fellow.preferred_name %} + {{ fellow.preferred_name }} + {% else %} + {{ fellow.legal_name }} + {% endif %} + + {% if fellow.city and fellow.country %} + — {{ fellow.city }}, {{ fellow.country }} + {% elif fellow.city %} + — {{ fellow.city }} + {% elif fellow.country %} + — {{ fellow.country }} + {% endif %} +
  • + {% endfor %} +
+ {% else %} +

No PSF Fellows found.

+ {% endif %} +
+{% endblock content %} From 26039aee283069a784533667815c96a8d6cca3d7 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:52:47 -0600 Subject: [PATCH 02/40] fix: include null expiry_round nominations in active() queryset use Q objects so nominations without a pre-created expiry round are not silently excluded from active views by the FK join filter. Co-Authored-By: Claude Opus 4.6 --- nominations/managers.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nominations/managers.py b/nominations/managers.py index e812ac2a1..890cc82b6 100644 --- a/nominations/managers.py +++ b/nominations/managers.py @@ -1,14 +1,17 @@ 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, filter by expiry_round still in future.""" + """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( - expiry_round__quarter_end__gte=timezone.now().date() + Q(expiry_round__quarter_end__gte=timezone.now().date()) + | Q(expiry_round__isnull=True) ) def for_round(self, round_obj): From 1af6d30fc222b0001cb963806dc14289f6a38692 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:52:52 -0600 Subject: [PATCH 03/40] fix: change cutoff boundary from < to <= so cutoff date is last open day nominations_cutoff date (e.g. feb 20) is now the last day nominations are accepted, matching the wg charter intent. Co-Authored-By: Claude Opus 4.6 --- nominations/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nominations/models.py b/nominations/models.py index 2dfad8149..77384660b 100644 --- a/nominations/models.py +++ b/nominations/models.py @@ -326,7 +326,7 @@ def is_current(self): @property def is_accepting_nominations(self): today = timezone.now().date() - return self.is_open and today < self.nominations_cutoff + return self.is_open and today <= self.nominations_cutoff @property def is_in_review(self): From 60a76d4c15b77eda94ecb639cb77a94c2988078e Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:52:57 -0600 Subject: [PATCH 04/40] fix: use save() loop in close_expired command to trigger post_save signal queryset.update() bypassed the post_save cdn purge signal and skipped setting auto_now fields. iterating with save() ensures cache invalidation. Co-Authored-By: Claude Opus 4.6 --- .../commands/close_expired_fellow_nominations.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nominations/management/commands/close_expired_fellow_nominations.py b/nominations/management/commands/close_expired_fellow_nominations.py index 730332224..829737e14 100644 --- a/nominations/management/commands/close_expired_fellow_nominations.py +++ b/nominations/management/commands/close_expired_fellow_nominations.py @@ -13,8 +13,11 @@ def handle(self, *args, **options): status__in=[FellowNomination.PENDING, FellowNomination.UNDER_REVIEW], expiry_round__quarter_end__lt=today, ) - count = expired.count() - expired.update(status=FellowNomination.NOT_ACCEPTED) + count = 0 + for nomination in expired: + nomination.status = FellowNomination.NOT_ACCEPTED + nomination.save() + count += 1 self.stdout.write( self.style.SUCCESS(f"Closed {count} expired Fellow nomination(s).") ) From d1629e71dae6a1e12dbad185ae7014c646a461d6 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:53:01 -0600 Subject: [PATCH 05/40] fix: change misleading "cast / update vote" link to "cast vote" the vote view is a createview with no update path; the old text implied votes could be changed after casting. Co-Authored-By: Claude Opus 4.6 --- templates/nominations/fellow_nomination_detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/nominations/fellow_nomination_detail.html b/templates/nominations/fellow_nomination_detail.html index 91cd67b3b..e23be0453 100644 --- a/templates/nominations/fellow_nomination_detail.html +++ b/templates/nominations/fellow_nomination_detail.html @@ -87,7 +87,7 @@

Individual Votes

{% endif %}

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

From 5f6eb40a01e26dda959878a321450cc3c47ae3ca Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:53:04 -0600 Subject: [PATCH 06/40] fix: replace deprecated
tag with styled div
is obsolete in html5; use text-align: center instead. Co-Authored-By: Claude Opus 4.6 --- templates/nominations/fellow_nomination_form.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/nominations/fellow_nomination_form.html b/templates/nominations/fellow_nomination_form.html index 7dd608efd..eb9f27cef 100644 --- a/templates/nominations/fellow_nomination_form.html +++ b/templates/nominations/fellow_nomination_form.html @@ -32,9 +32,9 @@

Nominate a PSF Fellow

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

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

From d42d0628c379cb82726c5a2c393e704e13c9ef79 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:53:14 -0600 Subject: [PATCH 07/40] fix: prevent duplicate nominations for same email in same round form-level check in clean_nominee_email rejects submissions when a nomination already exists for that email in the current open round. Co-Authored-By: Claude Opus 4.6 --- nominations/forms.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/nominations/forms.py b/nominations/forms.py index 3e7d2ba35..445e8f231 100644 --- a/nominations/forms.py +++ b/nominations/forms.py @@ -101,6 +101,18 @@ def clean_nominee_email(self): 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 From 9f84da8ffc3b573678662393c5a26cfdaadaf92d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:53:19 -0600 Subject: [PATCH 08/40] fix: disable year/quarter fields on round edit form prevents breaking the slug and fk relationships by changing year/quarter on existing rounds. uses disabled=true for tamper safety. Co-Authored-By: Claude Opus 4.6 --- nominations/forms.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/nominations/forms.py b/nominations/forms.py index 445e8f231..3eed942e3 100644 --- a/nominations/forms.py +++ b/nominations/forms.py @@ -169,8 +169,14 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Default year to current year on create (not edit) - if not self.instance.pk and not self.initial.get("year"): + 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", From bf0eee443f8d5850ede8f232fd5b0a575903d918 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:53:31 -0600 Subject: [PATCH 09/40] fix: allow superuser access in FellowWGRequiredMixin adds is_superuser check to match the access logic already used in FellowNominationDetail.get_object for consistency. Co-Authored-By: Claude Opus 4.6 --- nominations/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nominations/views.py b/nominations/views.py index e0174546a..5b7a624af 100644 --- a/nominations/views.py +++ b/nominations/views.py @@ -242,7 +242,7 @@ class FellowWGRequiredMixin(GroupRequiredMixin): raise_exception = True def check_membership(self, group): - if self.request.user.is_staff: + if self.request.user.is_staff or self.request.user.is_superuser: return True return super().check_membership(group) From 00160f94b3fc4fef82fb2c906018ccfdbfa60a8f Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:53:39 -0600 Subject: [PATCH 10/40] fix: check is_accepting_nominations on GET to prevent form past cutoff previously the nomination form rendered after the cutoff date but form_valid rejected the POST, giving users a confusing 404 on submit. Co-Authored-By: Claude Opus 4.6 --- nominations/views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nominations/views.py b/nominations/views.py index 5b7a624af..4f7f1ab77 100644 --- a/nominations/views.py +++ b/nominations/views.py @@ -263,8 +263,8 @@ def get_form_kwargs(self): 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: - raise Http404("No open Fellow nomination round at this time.") + 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 From 3532f6f8170c2f37093b2846f5166a7f3cb1dd59 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:53:46 -0600 Subject: [PATCH 11/40] fix: guard votes to only allow casting on under_review nominations prevents wg members from voting on pending, accepted, or not_accepted nominations which defeats the review workflow. Co-Authored-By: Claude Opus 4.6 --- nominations/views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nominations/views.py b/nominations/views.py index 4f7f1ab77..b496226fa 100644 --- a/nominations/views.py +++ b/nominations/views.py @@ -510,6 +510,14 @@ def get_context_data(self, **kwargs): 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: From 70c4c976ab785b2b086cbd3c5504e4c6b6cdf481 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:53:51 -0600 Subject: [PATCH 12/40] fix: reuse context queryset in roster view to avoid double db query self.get_queryset().count() fired a redundant query; context["fellows"] already holds the same queryset from the parent get_context_data. Co-Authored-By: Claude Opus 4.6 --- nominations/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nominations/views.py b/nominations/views.py index b496226fa..b1bfaf962 100644 --- a/nominations/views.py +++ b/nominations/views.py @@ -646,5 +646,5 @@ def get_queryset(self): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["total_count"] = self.get_queryset().count() + context["total_count"] = context["fellows"].count() return context \ No newline at end of file From 3d3df1631d5549d190c8a05dc58ec4624a9765d5 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:56:45 -0600 Subject: [PATCH 13/40] use default if no name set --- templates/nominations/fellow_nomination_detail.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/nominations/fellow_nomination_detail.html b/templates/nominations/fellow_nomination_detail.html index e23be0453..ff15e59fe 100644 --- a/templates/nominations/fellow_nomination_detail.html +++ b/templates/nominations/fellow_nomination_detail.html @@ -23,7 +23,7 @@

Fellow Nomination: {{ nomination.nominee_name }}

  • Nominee: {{ nomination.nominee_name }}
  • -
  • Nominated by: {{ nomination.nominator.get_full_name }}
  • +
  • Nominated by: {{ nomination.nominator.get_full_name|default:nomination.nominator.username }}
  • Round: {{ nomination.nomination_round }}
  • Status: From 87942061556e5dd098324c3ba4b59fa11f167fff Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:58:01 -0600 Subject: [PATCH 14/40] need some length reqs --- nominations/forms.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/nominations/forms.py b/nominations/forms.py index 3eed942e3..e0b181aee 100644 --- a/nominations/forms.py +++ b/nominations/forms.py @@ -87,13 +87,21 @@ class Meta: 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? Markdown supported.", + "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.raw.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: From 966af69e62906f349a107ec823fdc2d5c28d54fc Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 20:59:13 -0600 Subject: [PATCH 15/40] fix 'str' object has no attribute 'raw' --- nominations/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nominations/forms.py b/nominations/forms.py index e0b181aee..aee886b76 100644 --- a/nominations/forms.py +++ b/nominations/forms.py @@ -96,7 +96,7 @@ def __init__(self, *args, **kwargs): def clean_nomination_statement(self): statement = self.cleaned_data["nomination_statement"] - if len(statement.raw.strip()) < 120: + if len(statement.strip()) < 120: raise forms.ValidationError( "Please provide a more detailed nomination statement (at least 120 characters)." ) From b7a9c37ad896bcb63187b8770c1c7cccff1e42c4 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:11:07 -0600 Subject: [PATCH 16/40] rewrk roster page for types --- templates/nominations/fellows_roster.html | 47 +++++++++++++---------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/templates/nominations/fellows_roster.html b/templates/nominations/fellows_roster.html index f03d72218..6e4dbe4f6 100644 --- a/templates/nominations/fellows_roster.html +++ b/templates/nominations/fellows_roster.html @@ -1,7 +1,7 @@ {% extends "psf/default.html" %} {% block page_title %} -PSF Fellows | Python Software Foundation +Fellows of the Python Software Foundation {% endblock %} {% block body_attributes %}class="nominations fellows_roster"{% endblock %} @@ -16,30 +16,37 @@

    PSF Fellows

    There are currently {{ total_count }} PSF Fellows.

    - {% if fellows %} + {% if active_fellows %} +

    Fellows ({{ active_count }})

      - {% for fellow in fellows %} + {% for fellow in active_fellows %} +
    • {{ fellow.name }} ({{ fellow.year_elected }})
    • + {% endfor %} +
    + {% endif %} + + {% if emeritus_fellows %} +

    Emeritus Fellows ({{ emeritus_count }})

    +
      + {% for fellow in emeritus_fellows %} +
    • {{ fellow.name }} ({{ fellow.year_elected }}{% if fellow.emeritus_year %}/{{ fellow.emeritus_year }}{% endif %})
    • + {% endfor %} +
    + {% endif %} + + {% if deceased_fellows %} +

    In Memoriam ({{ deceased_count }})

    +
      + {% for fellow in deceased_fellows %}
    • - - {% if fellow.creator %} - {{ fellow.creator.get_full_name|default:fellow.creator.username }} - {% elif fellow.preferred_name %} - {{ fellow.preferred_name }} - {% else %} - {{ fellow.legal_name }} - {% endif %} - - {% if fellow.city and fellow.country %} - — {{ fellow.city }}, {{ fellow.country }} - {% elif fellow.city %} - — {{ fellow.city }} - {% elif fellow.country %} - — {{ fellow.country }} - {% endif %} + {{ fellow.name }} ({{ fellow.year_elected }}) + {% if fellow.notes %}
      {{ fellow.notes }}{% endif %}
    • {% endfor %}
    - {% else %} + {% endif %} + + {% if not active_fellows and not emeritus_fellows and not deceased_fellows %}

    No PSF Fellows found.

    {% endif %} From d300dc3053e4cf480e80caf0a4aed666e985359b Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:11:23 -0600 Subject: [PATCH 17/40] no factories wtf --- nominations/factories.py | 338 ------------------ .../commands/backfill_fellow_memberships.py | 201 ----------- .../commands/create_test_nomination_data.py | 304 ++++++++++++++++ 3 files changed, 304 insertions(+), 539 deletions(-) delete mode 100644 nominations/factories.py delete mode 100644 nominations/management/commands/backfill_fellow_memberships.py create mode 100644 nominations/management/commands/create_test_nomination_data.py diff --git a/nominations/factories.py b/nominations/factories.py deleted file mode 100644 index 777501ac5..000000000 --- a/nominations/factories.py +++ /dev/null @@ -1,338 +0,0 @@ -import datetime - -from django.contrib.auth.models import Group - -from users.factories import UserFactory -from users.models import Membership - -from .models import ( - FellowNomination, - FellowNominationRound, - FellowNominationVote, -) - - -def _create_user(username, first_name, last_name, email=None, is_staff=False): - """Create a user with a specific username (idempotent via get_or_create pattern).""" - from users.models import User - - 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() - return user - - -def _create_round(year, quarter, is_open=True): - """Create a FellowNominationRound with standard dates for the given quarter.""" - 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, _ = 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, - }, - ) - return obj - - -def _create_nomination(nominator, nominee_name, nominee_email, nomination_round, - status="pending", expiry_round=None, nominee_user=None, - nominee_is_fellow_at_submission=False): - """Create a FellowNomination.""" - 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 _create_fellow_membership(user, city="", country=""): - """Create a Fellow Membership for a user if one doesn't already exist.""" - try: - return user.membership - except Membership.DoesNotExist: - return Membership.objects.create( - creator=user, - membership_type=Membership.FELLOW, - legal_name=f"{user.first_name} {user.last_name}", - preferred_name=user.first_name, - email_address=user.email, - city=city, - country=country, - ) - - -def initial_data(): - # --- Phase 1: Groups, users, rounds, nominations --- - - wg_group, _ = Group.objects.get_or_create(name="PSF Fellow Work Group") - - # WG members (Phase 1 creates 1, Phase 2 adds 3 more = 4 total) - wg_member1 = _create_user("wg_alice", "Alice", "WGMember", "alice.wg@python.org") - wg_member2 = _create_user("wg_bob", "Bob", "Reviewer", "bob.wg@python.org") - wg_member3 = _create_user("wg_carol", "Carol", "Evaluator", "carol.wg@python.org") - wg_member4 = _create_user("wg_dave", "Dave", "Assessor", "dave.wg@python.org") - for member in [wg_member1, wg_member2, wg_member3, wg_member4]: - member.groups.add(wg_group) - - # Staff user (not in WG group) for testing staff fallback access - staff_user = _create_user("staff_admin", "Staff", "Admin", is_staff=True) - - # Regular nominators - nominator1 = _create_user("nominator1", "Nominator", "One", "nominator1@example.com") - nominator2 = _create_user("nominator2", "Nominator", "Two", "nominator2@example.com") - - # Rounds - past_round = _create_round(2025, 3, is_open=False) # 2025-Q3 (closed) - current_round = _create_round(2026, 1, is_open=True) # 2026-Q1 (open, current) - future_round = _create_round(2026, 2, is_open=False) # 2026-Q2 (future, empty) - expiry_round = _create_round(2026, 4, is_open=False) # 2026-Q4 (for expiry targets) - old_expiry = _create_round(2025, 2, is_open=False) # 2025-Q2 (past, for expired nom) - - # --- Past round nominations (2025-Q3) --- - past_accepted1 = _create_nomination( - nominator1, "Past Accepted One", "past1@example.com", - past_round, status="accepted", - ) - past_accepted2 = _create_nomination( - nominator2, "Past Accepted Two", "past2@example.com", - past_round, status="accepted", - ) - past_not_accepted = _create_nomination( - nominator1, "Past Not Accepted", "past_na@example.com", - past_round, status="not_accepted", - ) - - # --- Current round nominations (2026-Q1) --- - - # 3 pending nominations - pending1 = _create_nomination( - nominator1, "Pending Person One", "pending1@example.com", - current_round, status="pending", expiry_round=expiry_round, - ) - pending2 = _create_nomination( - nominator2, "Pending Person Two", "pending2@example.com", - current_round, status="pending", expiry_round=expiry_round, - ) - pending3 = _create_nomination( - nominator1, "Pending Person Three", "pending3@example.com", - current_round, status="pending", expiry_round=expiry_round, - ) - - # 2 under_review nominations (will have votes added in Phase 2 section) - under_review_majority_yes = _create_nomination( - nominator1, "Review Majority Yes", "review_yes@example.com", - current_round, status="under_review", expiry_round=expiry_round, - ) - under_review_majority_no = _create_nomination( - nominator2, "Review Majority No", "review_no@example.com", - current_round, status="under_review", expiry_round=expiry_round, - ) - - # Additional under_review for vote scenarios - under_review_tie = _create_nomination( - nominator1, "Review Tie Vote", "review_tie@example.com", - current_round, status="under_review", expiry_round=expiry_round, - ) - under_review_abstains = _create_nomination( - nominator2, "Review All Abstain", "review_abstain@example.com", - current_round, status="under_review", expiry_round=expiry_round, - ) - under_review_one_vote = _create_nomination( - nominator1, "Review One Vote", "review_onevote@example.com", - current_round, status="under_review", expiry_round=expiry_round, - ) - - # 1 accepted in current round - current_accepted = _create_nomination( - nominator2, "Current Accepted", "current_accepted@example.com", - current_round, status="accepted", - ) - - # 1 nomination where nominee is already a Fellow - fellow_nominee_user = _create_user("already_fellow", "Already", "Fellow", "already_fellow@example.com") - _create_fellow_membership(fellow_nominee_user, city="San Francisco", country="USA") - already_fellow_nom = _create_nomination( - nominator1, "Already Fellow", "already_fellow@example.com", - current_round, status="pending", expiry_round=expiry_round, - nominee_user=fellow_nominee_user, nominee_is_fellow_at_submission=True, - ) - - # 1 expired nomination (expiry_round in the past, still pending) - expired_nom = _create_nomination( - nominator2, "Expired Pending", "expired@example.com", - past_round, status="pending", expiry_round=old_expiry, - ) - - # --- Phase 2: Votes on under_review nominations --- - - # Majority yes (3 yes, 1 no) — threshold met - FellowNominationVote.objects.get_or_create( - nomination=under_review_majority_yes, voter=wg_member1, - defaults={"vote": "yes", "comment": "Strong contributor."}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_majority_yes, voter=wg_member2, - defaults={"vote": "yes", "comment": "Agree."}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_majority_yes, voter=wg_member3, - defaults={"vote": "yes", "comment": "Excellent candidate."}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_majority_yes, voter=wg_member4, - defaults={"vote": "no", "comment": "Need more info."}) - - # Majority no (1 yes, 2 no, 1 abstain) — threshold not met - FellowNominationVote.objects.get_or_create( - nomination=under_review_majority_no, voter=wg_member1, - defaults={"vote": "yes", "comment": "Good work."}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_majority_no, voter=wg_member2, - defaults={"vote": "no", "comment": "Insufficient contributions."}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_majority_no, voter=wg_member3, - defaults={"vote": "no", "comment": "Not yet."}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_majority_no, voter=wg_member4, - defaults={"vote": "abstain", "comment": "Conflict of interest."}) - - # Tie (2 yes, 2 no) — threshold not met - FellowNominationVote.objects.get_or_create( - nomination=under_review_tie, voter=wg_member1, - defaults={"vote": "yes"}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_tie, voter=wg_member2, - defaults={"vote": "yes"}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_tie, voter=wg_member3, - defaults={"vote": "no"}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_tie, voter=wg_member4, - defaults={"vote": "no"}) - - # All abstains — no result - FellowNominationVote.objects.get_or_create( - nomination=under_review_abstains, voter=wg_member1, - defaults={"vote": "abstain"}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_abstains, voter=wg_member2, - defaults={"vote": "abstain"}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_abstains, voter=wg_member3, - defaults={"vote": "abstain"}) - FellowNominationVote.objects.get_or_create( - nomination=under_review_abstains, voter=wg_member4, - defaults={"vote": "abstain"}) - - # One vote cast — voting in progress - FellowNominationVote.objects.get_or_create( - nomination=under_review_one_vote, voter=wg_member1, - defaults={"vote": "yes", "comment": "Looks promising."}) - - # --- Phase 3: Fellow Membership records for public roster --- - - # Fellows with full profiles (some went through nomination flow) - fellow1 = _create_user("guido_van_rossum", "Guido", "van Rossum", "guido@python.org") - _create_fellow_membership(fellow1, city="Belmont", country="USA") - - fellow2 = _create_user("carol_willing", "Carol", "Willing", "carol@python.org") - _create_fellow_membership(fellow2, city="San Diego", country="USA") - - fellow3 = _create_user("mariatta_wijaya", "Mariatta", "Wijaya", "mariatta@python.org") - _create_fellow_membership(fellow3, city="Vancouver", country="Canada") - - fellow4 = _create_user("naomi_ceder", "Naomi", "Ceder", "naomi@python.org") - _create_fellow_membership(fellow4, city="Houston", country="USA") - - fellow5 = _create_user("victor_stinner", "Victor", "Stinner", "victor@python.org") - _create_fellow_membership(fellow5, city="", country="France") - - # Fellows without full location - fellow6 = _create_user("brett_cannon", "Brett", "Cannon", "brett@python.org") - _create_fellow_membership(fellow6) # No city/country - - fellow7 = _create_user("barry_warsaw", "Barry", "Warsaw", "barry@python.org") - _create_fellow_membership(fellow7, city="Boston", country="") # City only - - # already_fellow_nom user already has a Fellow membership from above - - # Non-Fellow memberships (should NOT appear on roster) - basic_user = _create_user("basic_member", "Basic", "Member", "basic@example.com") - try: - basic_user.membership - except Membership.DoesNotExist: - Membership.objects.create( - creator=basic_user, - membership_type=Membership.BASIC, - legal_name="Basic Member", - preferred_name="Basic", - email_address=basic_user.email, - ) - - supporting_user = _create_user("supporting_member", "Supporting", "Member", "supporting@example.com") - try: - supporting_user.membership - except Membership.DoesNotExist: - Membership.objects.create( - creator=supporting_user, - membership_type=Membership.SUPPORTING, - legal_name="Supporting Member", - preferred_name="Supporting", - email_address=supporting_user.email, - ) - - contributing_user = _create_user("contributing_member", "Contributing", "Member", "contributing@example.com") - try: - contributing_user.membership - except Membership.DoesNotExist: - Membership.objects.create( - creator=contributing_user, - membership_type=Membership.CONTRIBUTING, - legal_name="Contributing Member", - preferred_name="Contributing", - email_address=contributing_user.email, - ) - - return { - "groups": [wg_group], - "wg_members": [wg_member1, wg_member2, wg_member3, wg_member4], - "staff": [staff_user], - "nominators": [nominator1, nominator2], - "rounds": [past_round, current_round, future_round, expiry_round], - "nominations": [ - past_accepted1, past_accepted2, past_not_accepted, - pending1, pending2, pending3, - under_review_majority_yes, under_review_majority_no, - under_review_tie, under_review_abstains, under_review_one_vote, - current_accepted, already_fellow_nom, expired_nom, - ], - "fellows": [fellow1, fellow2, fellow3, fellow4, fellow5, fellow6, fellow7, fellow_nominee_user], - } diff --git a/nominations/management/commands/backfill_fellow_memberships.py b/nominations/management/commands/backfill_fellow_memberships.py deleted file mode 100644 index 0b0a7dbbc..000000000 --- a/nominations/management/commands/backfill_fellow_memberships.py +++ /dev/null @@ -1,201 +0,0 @@ -import csv - -from django.core.management.base import BaseCommand -from django.db import transaction - -from users.models import Membership, User - - -class Command(BaseCommand): - help = ( - "One-time backfill script to create Membership records (type=FELLOW) " - "from a CSV data source." - ) - - def add_arguments(self, parser): - parser.add_argument( - "--csv", - required=True, - help="Path to the CSV file with columns: email, first_name, last_name, city, country", - ) - parser.add_argument( - "--dry-run", - action="store_true", - default=False, - help="Print what would be done without making changes.", - ) - - def handle(self, *args, **options): - csv_path = options["csv"] - dry_run = options["dry_run"] - - if dry_run: - self.stdout.write(self.style.WARNING("DRY RUN mode enabled. No changes will be made.\n")) - - rows = self._read_csv(csv_path) - if rows is None: - return - - if dry_run: - self._process_rows(rows, dry_run=True) - else: - with transaction.atomic(): - self._process_rows(rows, dry_run=False) - - def _read_csv(self, csv_path): - """Read and return rows from the CSV file, or None on error.""" - try: - with open(csv_path, newline="", encoding="utf-8") as f: - reader = csv.DictReader(f) - return list(reader) - except FileNotFoundError: - self.stderr.write(self.style.ERROR(f"CSV file not found: {csv_path}")) - return None - except Exception as e: - self.stderr.write(self.style.ERROR(f"Error reading CSV: {e}")) - return None - - def _process_rows(self, rows, *, dry_run): - """Process all CSV rows, creating users and memberships as needed.""" - created_count = 0 - skipped_count = 0 - error_count = 0 - - for line_num, row in enumerate(rows, start=2): # start=2 because line 1 is the header - email = (row.get("email") or "").strip() - if not email: - self.stderr.write( - self.style.WARNING(f"Line {line_num}: Skipping row with missing/empty email.") - ) - skipped_count += 1 - continue - - first_name = (row.get("first_name") or "").strip() - last_name = (row.get("last_name") or "").strip() - city = (row.get("city") or "").strip() - country = (row.get("country") or "").strip() - legal_name = f"{first_name} {last_name}".strip() - - try: - result = self._process_single_row( - email=email, - first_name=first_name, - last_name=last_name, - legal_name=legal_name, - city=city, - country=country, - dry_run=dry_run, - line_num=line_num, - ) - if result == "created": - created_count += 1 - elif result == "skipped": - skipped_count += 1 - except Exception as e: - self.stderr.write( - self.style.ERROR(f"Line {line_num}: Error processing {email}: {e}") - ) - error_count += 1 - - # Print summary - self.stdout.write("") - prefix = "[DRY RUN] " if dry_run else "" - self.stdout.write(self.style.SUCCESS(f"{prefix}Summary:")) - self.stdout.write(f" Created: {created_count}") - self.stdout.write(f" Skipped: {skipped_count}") - self.stdout.write(f" Errors: {error_count}") - - def _process_single_row(self, *, email, first_name, last_name, legal_name, city, country, dry_run, line_num): - """ - Process a single CSV row. Returns 'created' or 'skipped'. - Raises on unexpected errors. - """ - email_lower = email.lower() - - # Look up existing user (case-insensitive email match) - try: - user = User.objects.get(email__iexact=email_lower) - except User.DoesNotExist: - user = None - except User.MultipleObjectsReturned: - self.stderr.write( - self.style.WARNING( - f"Line {line_num}: Multiple users found for {email}. Skipping." - ) - ) - return "skipped" - - # Check for existing membership of any type - if user is not None: - try: - existing = user.membership - if existing is not None: - if existing.membership_type == Membership.FELLOW: - self.stdout.write( - f"Line {line_num}: {email} already has a Fellow membership. Skipping." - ) - else: - membership_label = existing.get_membership_type_display() - self.stderr.write( - self.style.WARNING( - f"Line {line_num}: {email} already has a '{membership_label}' " - f"membership. Skipping." - ) - ) - return "skipped" - except Membership.DoesNotExist: - pass # No membership yet, proceed to create one - - if dry_run: - action = "create user + " if user is None else "" - self.stdout.write( - f"[DRY RUN] Line {line_num}: Would {action}create Fellow membership " - f"for {email} ({legal_name})" - ) - return "created" - - # Create user if needed - if user is None: - username = self._generate_username(email_lower) - user = User.objects.create_user( - username=username, - email=email_lower, - first_name=first_name, - last_name=last_name, - ) - self.stdout.write(f"Line {line_num}: Created user '{username}' for {email}.") - - # Create the Fellow membership - Membership.objects.create( - creator=user, - membership_type=Membership.FELLOW, - legal_name=legal_name, - preferred_name=first_name, - email_address=email_lower, - city=city, - country=country, - ) - self.stdout.write( - self.style.SUCCESS( - f"Line {line_num}: Created Fellow membership for {email} ({legal_name})." - ) - ) - return "created" - - def _generate_username(self, email): - """ - Generate a unique username from the email address. - Uses the local part of the email, appending a numeric suffix if needed. - """ - base = email.split("@")[0] - # Sanitize: keep only alphanumeric, dots, hyphens, underscores - base = "".join(c for c in base if c.isalnum() or c in ".-_") - if not base: - base = "fellow" - - username = base - suffix = 1 - while User.objects.filter(username=username).exists(): - username = f"{base}{suffix}" - suffix += 1 - return username 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..69259fed8 --- /dev/null +++ b/nominations/management/commands/create_test_nomination_data.py @@ -0,0 +1,304 @@ +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 i, (nominator, name, email) in enumerate([ + (self.nominator1, "Pending Person One", "pending1@example.com"), + (self.nominator2, "Pending Person Two", "pending2@example.com"), + (self.nominator1, "Pending Person Three", "pending3@example.com"), + ], 1): + 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.") From 8f96d75deedfe6991d6f724e4a43b14a1c8b267e Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:11:35 -0600 Subject: [PATCH 18/40] baseline data for existing fellows --- fixtures/fellows.json | 5774 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 5774 insertions(+) create mode 100644 fixtures/fellows.json 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 + } + } +] From 9581f6d16b7346be55aec92cb5a3689343920744 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:12:57 -0600 Subject: [PATCH 19/40] introduce Fellow model that is more extensible --- nominations/admin.py | 9 ++++++ nominations/migrations/0004_fellow.py | 31 +++++++++++++++++++ nominations/models.py | 44 +++++++++++++++++++++++++-- nominations/views.py | 22 ++++++++------ 4 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 nominations/migrations/0004_fellow.py diff --git a/nominations/admin.py b/nominations/admin.py index 9f474ee16..73d5688bf 100644 --- a/nominations/admin.py +++ b/nominations/admin.py @@ -4,6 +4,7 @@ from nominations.models import ( Election, + Fellow, FellowNomination, FellowNominationRound, FellowNominationVote, @@ -38,6 +39,14 @@ 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") diff --git a/nominations/migrations/0004_fellow.py b/nominations/migrations/0004_fellow.py new file mode 100644 index 000000000..2a4ec1676 --- /dev/null +++ b/nominations/migrations/0004_fellow.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.28 on 2026-02-06 03:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +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 77384660b..b67dcefa0 100644 --- a/nominations/models.py +++ b/nominations/models.py @@ -1,6 +1,7 @@ import datetime from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver @@ -12,7 +13,7 @@ from markupfield.fields import MarkupField from cms.models import ContentManageable -from users.models import Membership, User +from users.models import User from .managers import FellowNominationQuerySet @@ -278,6 +279,43 @@ def purge_nomination_pages(sender, instance, created, **kwargs): ) +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.""" @@ -413,8 +451,8 @@ def is_active(self): def nominee_is_already_fellow(self): if self.nominee_user: try: - return self.nominee_user.membership.membership_type == Membership.FELLOW - except Membership.DoesNotExist: + return self.nominee_user.fellow is not None + except Fellow.DoesNotExist: return False return False diff --git a/nominations/views.py b/nominations/views.py index b1bfaf962..cee39079d 100644 --- a/nominations/views.py +++ b/nominations/views.py @@ -17,8 +17,6 @@ from pydotorg.mixins import GroupRequiredMixin, LoginRequiredMixin -from users.models import Membership - from .forms import ( FellowNominationForm, FellowNominationManageForm, @@ -31,6 +29,7 @@ ) from .models import ( Election, + Fellow, FellowNomination, FellowNominationRound, FellowNominationVote, @@ -294,14 +293,14 @@ def form_valid(self, form): form.instance.nominee_user = nominee_user # Check if nominee is already a Fellow try: - if nominee_user.membership.membership_type == Membership.FELLOW: + 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 Membership.DoesNotExist: + except Fellow.DoesNotExist: pass except User.DoesNotExist: pass @@ -638,13 +637,16 @@ class FellowsRoster(ListView): context_object_name = "fellows" def get_queryset(self): - return Membership.objects.filter( - membership_type=Membership.FELLOW, - ).select_related("creator").order_by( - "creator__last_name", "creator__first_name" - ) + return Fellow.objects.all() def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) - context["total_count"] = context["fellows"].count() + 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() return context \ No newline at end of file From 6d1e44bfb19975336fccd451b6f863feac0bd69c Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:13:24 -0600 Subject: [PATCH 20/40] update tests: use absolute imports and Fellow model Replace relative dot imports with absolute `nominations.tests.factories` imports. Migrate test assertions from Membership to Fellow model. Fix nomination_statement min-length in test data. Co-Authored-By: Claude Opus 4.6 --- nominations/tests/test_forms.py | 4 +- nominations/tests/test_management_views.py | 2 +- nominations/tests/test_models.py | 29 +---- nominations/tests/test_review_views.py | 2 +- nominations/tests/test_roster_views.py | 142 ++++++++------------- nominations/tests/test_views.py | 20 ++- 6 files changed, 72 insertions(+), 127 deletions(-) diff --git a/nominations/tests/test_forms.py b/nominations/tests/test_forms.py index a3f374feb..3d9541538 100644 --- a/nominations/tests/test_forms.py +++ b/nominations/tests/test_forms.py @@ -4,7 +4,7 @@ from nominations.forms import FellowNominationForm from nominations.models import FellowNominationRound -from .factories import UserFactory, FellowNominationRoundFactory +from nominations.tests.factories import UserFactory, FellowNominationRoundFactory class FellowNominationFormTests(TestCase): @@ -19,7 +19,7 @@ def test_valid_form(self): data = { "nominee_name": "Jane Doe", "nominee_email": "jane@example.com", - "nomination_statement": "Great contributor to Python.", + "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) diff --git a/nominations/tests/test_management_views.py b/nominations/tests/test_management_views.py index 46a69484f..27d6419dd 100644 --- a/nominations/tests/test_management_views.py +++ b/nominations/tests/test_management_views.py @@ -5,7 +5,7 @@ from django.contrib.auth.models import Group from nominations.models import FellowNomination, FellowNominationRound -from .factories import ( +from nominations.tests.factories import ( UserFactory, FellowNominationRoundFactory, FellowNominationFactory, diff --git a/nominations/tests/test_models.py b/nominations/tests/test_models.py index 05929976d..a016eb462 100644 --- a/nominations/tests/test_models.py +++ b/nominations/tests/test_models.py @@ -4,14 +4,13 @@ from django.test import TestCase from django.utils import timezone -from users.models import Membership - from nominations.models import ( + Fellow, FellowNominationRound, FellowNomination, FellowNominationVote, ) -from .factories import ( +from nominations.tests.factories import ( UserFactory, FellowNominationRoundFactory, FellowNominationFactory, @@ -153,35 +152,21 @@ 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_membership(self): + 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() - Membership.objects.create( - creator=nominee_user, - membership_type=Membership.FELLOW, - legal_name="Test Fellow", - preferred_name="Test", - email_address=nominee_user.email, + 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) - def test_nominee_is_already_fellow_false_basic_member(self): - nominee_user = UserFactory() - Membership.objects.create( - creator=nominee_user, - membership_type=Membership.BASIC, - legal_name="Test Basic", - preferred_name="Test", - email_address=nominee_user.email, - ) - self.nomination.nominee_user = nominee_user - self.assertFalse(self.nomination.nominee_is_already_fellow) - class FellowNominationVoteResultTests(TestCase): def setUp(self): diff --git a/nominations/tests/test_review_views.py b/nominations/tests/test_review_views.py index 6ef46822d..e36d07336 100644 --- a/nominations/tests/test_review_views.py +++ b/nominations/tests/test_review_views.py @@ -7,7 +7,7 @@ from django.contrib.auth.models import Group from nominations.models import FellowNomination, FellowNominationVote -from .factories import ( +from nominations.tests.factories import ( UserFactory, FellowNominationRoundFactory, FellowNominationFactory, diff --git a/nominations/tests/test_roster_views.py b/nominations/tests/test_roster_views.py index d8176d6a7..fddaf3a67 100644 --- a/nominations/tests/test_roster_views.py +++ b/nominations/tests/test_roster_views.py @@ -1,9 +1,7 @@ from django.test import TestCase, Client from django.urls import reverse -from users.models import Membership - -from .factories import UserFactory +from nominations.models import Fellow class FellowsRosterViewTests(TestCase): @@ -23,62 +21,18 @@ def test_alt_url_works(self): self.assertEqual(response.status_code, 200) def test_only_fellows_shown(self): - """Only members with membership_type=FELLOW should appear on the roster.""" - fellow_user = UserFactory(first_name="Alice", last_name="Fellow") - Membership.objects.create( - creator=fellow_user, - membership_type=Membership.FELLOW, - legal_name="Alice Fellow", - preferred_name="Alice", - email_address=fellow_user.email, - ) - basic_user = UserFactory(first_name="Bob", last_name="Basic") - Membership.objects.create( - creator=basic_user, - membership_type=Membership.BASIC, - legal_name="Bob Basic", - preferred_name="Bob", - email_address=basic_user.email, - ) - supporting_user = UserFactory(first_name="Carol", last_name="Supporter") - Membership.objects.create( - creator=supporting_user, - membership_type=Membership.SUPPORTING, - legal_name="Carol Supporter", - preferred_name="Carol", - email_address=supporting_user.email, - ) + """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.assertNotContains(response, "Bob Basic") - self.assertNotContains(response, "Carol Supporter") + self.assertContains(response, "Bob Emeritus") def test_alphabetical_ordering(self): - """Fellows should be ordered by last name, then first name.""" - user_z = UserFactory(first_name="Zara", last_name="Zebra") - Membership.objects.create( - creator=user_z, - membership_type=Membership.FELLOW, - legal_name="Zara Zebra", - preferred_name="Zara", - email_address=user_z.email, - ) - user_a = UserFactory(first_name="Alice", last_name="Alpha") - Membership.objects.create( - creator=user_a, - membership_type=Membership.FELLOW, - legal_name="Alice Alpha", - preferred_name="Alice", - email_address=user_a.email, - ) - user_m = UserFactory(first_name="Mike", last_name="Middle") - Membership.objects.create( - creator=user_m, - membership_type=Membership.FELLOW, - legal_name="Mike Middle", - preferred_name="Mike", - email_address=user_m.email, - ) + """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") @@ -90,17 +44,11 @@ def test_alphabetical_ordering(self): def test_total_count_in_context(self): """The context should include the total count of Fellows.""" for i in range(3): - user = UserFactory(first_name=f"Fellow{i}", last_name=f"User{i}") - Membership.objects.create( - creator=user, - membership_type=Membership.FELLOW, - legal_name=f"Fellow{i} User{i}", - preferred_name=f"Fellow{i}", - email_address=user.email, + 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) - self.assertContains(response, "3") def test_empty_roster(self): """When there are no Fellows, an appropriate message should be shown.""" @@ -108,34 +56,50 @@ def test_empty_roster(self): self.assertEqual(response.status_code, 200) self.assertContains(response, "No PSF Fellows found") - def test_fellow_with_location(self): - """Fellow with city and country should display location info.""" - user = UserFactory(first_name="Located", last_name="Fellow") - Membership.objects.create( - creator=user, - membership_type=Membership.FELLOW, - legal_name="Located Fellow", - preferred_name="Located", - email_address=user.email, - city="Portland", - country="USA", + 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, "Year Fellow (2019)") + + def test_emeritus_year_displayed(self): + """Emeritus fellows should show both elected 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, "Portland") - self.assertContains(response, "USA") + self.assertContains(response, "Old Fellow (2005/2020)") - def test_fellow_without_location(self): - """Fellow without city/country should still render without errors.""" - user = UserFactory(first_name="NoLoc", last_name="Fellow") - Membership.objects.create( - creator=user, - membership_type=Membership.FELLOW, - legal_name="NoLoc Fellow", - preferred_name="NoLoc", - email_address=user.email, - city="", - country="", + 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.assertEqual(response.status_code, 200) - self.assertContains(response, "NoLoc Fellow") + 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_section_headings_rendered(self): + """Each section heading should appear when fellows of that status 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, "Fellows (1)") + self.assertContains(response, "Emeritus Fellows (1)") + self.assertContains(response, "In Memoriam (1)") diff --git a/nominations/tests/test_views.py b/nominations/tests/test_views.py index f43827edf..1a2f1d1bb 100644 --- a/nominations/tests/test_views.py +++ b/nominations/tests/test_views.py @@ -6,10 +6,8 @@ from django.utils import timezone from django.contrib.auth.models import Group -from users.models import Membership - -from nominations.models import FellowNomination, FellowNominationRound -from .factories import ( +from nominations.models import Fellow, FellowNomination, FellowNominationRound +from nominations.tests.factories import ( UserFactory, FellowNominationRoundFactory, FellowNominationFactory, @@ -58,7 +56,7 @@ def test_successful_submission(self, mock_now, mock_wg_notify, mock_nominator_no data = { "nominee_name": "Jane Doe", "nominee_email": "jane@example.com", - "nomination_statement": "Great contributions.", + "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) @@ -76,17 +74,15 @@ def test_fellow_warning_shown(self, mock_now, mock_wg_notify, mock_nominator_not datetime.datetime(2026, 1, 15, 12, 0) ) fellow_user = UserFactory(email="fellow@example.com") - Membership.objects.create( - creator=fellow_user, - membership_type=Membership.FELLOW, - legal_name="Fellow User", - preferred_name="Fellow", - email_address="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": "Already a fellow.", + "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", } response = self.client.post(self.url, data, follow=True) From e3eb1fd4b89f17c4d4c8590011aca5700a53f1d5 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:15:46 -0600 Subject: [PATCH 21/40] unused import --- nominations/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nominations/models.py b/nominations/models.py index b67dcefa0..538df43e6 100644 --- a/nominations/models.py +++ b/nominations/models.py @@ -1,7 +1,6 @@ import datetime from django.conf import settings -from django.core.exceptions import ObjectDoesNotExist from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver From 00c414142785507ace1f75a8a4543b5e3b084bf6 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:34:52 -0600 Subject: [PATCH 22/40] relative imports --- nominations/forms.py | 2 +- nominations/models.py | 2 +- nominations/urls.py | 2 +- nominations/views.py | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/nominations/forms.py b/nominations/forms.py index aee886b76..0e2ba88fb 100644 --- a/nominations/forms.py +++ b/nominations/forms.py @@ -5,7 +5,7 @@ from markupfield.widgets import MarkupTextarea -from .models import ( +from nominations.models import ( FellowNomination, FellowNominationRound, FellowNominationVote, diff --git a/nominations/models.py b/nominations/models.py index 538df43e6..ac7ac8516 100644 --- a/nominations/models.py +++ b/nominations/models.py @@ -14,7 +14,7 @@ from cms.models import ContentManageable from users.models import User -from .managers import FellowNominationQuerySet +from nominations.managers import FellowNominationQuerySet class Election(models.Model): diff --git a/nominations/urls.py b/nominations/urls.py index 6ba200f27..25985d9f8 100644 --- a/nominations/urls.py +++ b/nominations/urls.py @@ -1,4 +1,4 @@ -from . import views +from nominations import views from django.urls import path app_name = "nominations" diff --git a/nominations/views.py b/nominations/views.py index cee39079d..98cca3530 100644 --- a/nominations/views.py +++ b/nominations/views.py @@ -17,7 +17,7 @@ from pydotorg.mixins import GroupRequiredMixin, LoginRequiredMixin -from .forms import ( +from nominations.forms import ( FellowNominationForm, FellowNominationManageForm, FellowNominationRoundForm, @@ -27,7 +27,7 @@ NominationCreateForm, NominationForm, ) -from .models import ( +from nominations.models import ( Election, Fellow, FellowNomination, @@ -36,7 +36,7 @@ Nomination, Nominee, ) -from .notifications import ( +from nominations.notifications import ( FellowNominationAcceptedNotification, FellowNominationNotAcceptedNotification, FellowNominationSubmittedToNominator, From 9eb4315e7a8e56a63605e77e9a5a2ca074650adb Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:35:13 -0600 Subject: [PATCH 23/40] apply ruff --- .../close_expired_fellow_nominations.py | 4 +- .../commands/create_test_nomination_data.py | 195 ++++++++++++------ nominations/managers.py | 7 +- .../migrations/0003_fellow_nominations.py | 194 +++++++++++++---- nominations/migrations/0004_fellow.py | 40 ++-- nominations/tests/factories.py | 3 +- nominations/tests/test_forms.py | 7 +- nominations/tests/test_management_views.py | 44 ++-- nominations/tests/test_models.py | 77 +++---- nominations/tests/test_review_views.py | 44 ++-- nominations/tests/test_roster_views.py | 10 +- nominations/tests/test_views.py | 23 +-- 12 files changed, 386 insertions(+), 262 deletions(-) diff --git a/nominations/management/commands/close_expired_fellow_nominations.py b/nominations/management/commands/close_expired_fellow_nominations.py index 829737e14..d9b036089 100644 --- a/nominations/management/commands/close_expired_fellow_nominations.py +++ b/nominations/management/commands/close_expired_fellow_nominations.py @@ -18,6 +18,4 @@ def handle(self, *args, **options): nomination.status = FellowNomination.NOT_ACCEPTED nomination.save() count += 1 - self.stdout.write( - self.style.SUCCESS(f"Closed {count} expired Fellow nomination(s).") - ) + 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 index 69259fed8..9af25936b 100644 --- a/nominations/management/commands/create_test_nomination_data.py +++ b/nominations/management/commands/create_test_nomination_data.py @@ -36,13 +36,15 @@ def handle(self, *args, **options): 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" - )) + 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 --------------------------------------------------------------- @@ -64,14 +66,30 @@ def _get_or_create_user(self, username, first_name, last_name, email=None, is_st 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)), + 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( @@ -90,16 +108,22 @@ def _get_or_create_round(self, year, quarter, is_open=True): 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): + 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_statement=(f"{nominee_name} has made outstanding contributions to the Python community."), nomination_round=nomination_round, status=status, expiry_round=expiry_round, @@ -107,8 +131,7 @@ def _create_nomination(self, nominator, nominee_name, nominee_email, nomination_ 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): + 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={ @@ -139,15 +162,9 @@ def _create_groups_and_users(self): 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" - ) + 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...") @@ -163,55 +180,91 @@ def _create_nominations(self): # Past round (2025-Q3) self._create_nomination( - self.nominator1, "Past Accepted One", "past1@example.com", - self.past_round, status="accepted", + 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.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", + self.nominator1, + "Past Not Accepted", + "past_na@example.com", + self.past_round, + status="not_accepted", ) # Current round (2026-Q1) — pending - for i, (nominator, name, email) in enumerate([ + 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"), - ], 1): + ]: self._create_nomination( - nominator, name, email, - self.current_round, status="pending", expiry_round=self.expiry_round, + 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.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.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.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.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, + 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", + self.nominator2, + "Current Accepted", + "current_accepted@example.com", + self.current_round, + status="accepted", ) # Nominee who is already a Fellow @@ -220,15 +273,24 @@ def _create_nominations(self): ) 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, + 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.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") @@ -245,7 +307,8 @@ def _create_votes(self): (wg4, "no", "Need more info."), ]: FellowNominationVote.objects.get_or_create( - nomination=self.under_review_majority_yes, voter=voter, + nomination=self.under_review_majority_yes, + voter=voter, defaults={"vote": vote, "comment": comment}, ) @@ -257,27 +320,31 @@ def _create_votes(self): (wg4, "abstain", "Conflict of interest."), ]: FellowNominationVote.objects.get_or_create( - nomination=self.under_review_majority_no, voter=voter, + 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, + 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, + 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, + nomination=self.under_review_one_vote, + voter=wg1, defaults={"vote": "yes", "comment": "Looks promising."}, ) diff --git a/nominations/managers.py b/nominations/managers.py index 890cc82b6..cd5ba2135 100644 --- a/nominations/managers.py +++ b/nominations/managers.py @@ -7,11 +7,8 @@ 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) + 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): diff --git a/nominations/migrations/0003_fellow_nominations.py b/nominations/migrations/0003_fellow_nominations.py index 0e8dac1ca..b397c7960 100644 --- a/nominations/migrations/0003_fellow_nominations.py +++ b/nominations/migrations/0003_fellow_nominations.py @@ -1,75 +1,183 @@ # Generated by Django 4.2.28 on 2026-02-06 01:17 -from django.conf import settings -from django.db import migrations, models 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'), + ("nominations", "0002_auto_20190514_1435"), ] operations = [ migrations.CreateModel( - name='FellowNominationRound', + 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)), + ("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')}, + "ordering": ["-year", "-quarter"], + "unique_together": {("year", "quarter")}, }, ), migrations.CreateModel( - name='FellowNomination', + 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)), - ('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)), + ("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)), + ( + "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'], + "ordering": ["-created"], }, ), migrations.CreateModel( - name='FellowNominationVote', + 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)), + ("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')}, + "unique_together": {("nomination", "voter")}, }, ), ] diff --git a/nominations/migrations/0004_fellow.py b/nominations/migrations/0004_fellow.py index 2a4ec1676..a2712b0f6 100644 --- a/nominations/migrations/0004_fellow.py +++ b/nominations/migrations/0004_fellow.py @@ -1,31 +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 -import django.db.models.deletion class Migration(migrations.Migration): - dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('nominations', '0003_fellow_nominations'), + ("nominations", "0003_fellow_nominations"), ] operations = [ migrations.CreateModel( - name='Fellow', + 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)), + ("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'], + "ordering": ["name"], }, ), ] diff --git a/nominations/tests/factories.py b/nominations/tests/factories.py index 586b594f0..e71c4c8d6 100644 --- a/nominations/tests/factories.py +++ b/nominations/tests/factories.py @@ -1,9 +1,10 @@ import datetime + import factory from factory.django import DjangoModelFactory +from nominations.models import FellowNomination, FellowNominationRound from users.models import User -from nominations.models import FellowNominationRound, FellowNomination class UserFactory(DjangoModelFactory): diff --git a/nominations/tests/test_forms.py b/nominations/tests/test_forms.py index 3d9541538..223485da8 100644 --- a/nominations/tests/test_forms.py +++ b/nominations/tests/test_forms.py @@ -1,10 +1,7 @@ -from django.test import TestCase, RequestFactory - -from users.models import User +from django.test import RequestFactory, TestCase from nominations.forms import FellowNominationForm -from nominations.models import FellowNominationRound -from nominations.tests.factories import UserFactory, FellowNominationRoundFactory +from nominations.tests.factories import FellowNominationRoundFactory, UserFactory class FellowNominationFormTests(TestCase): diff --git a/nominations/tests/test_management_views.py b/nominations/tests/test_management_views.py index 27d6419dd..b68089253 100644 --- a/nominations/tests/test_management_views.py +++ b/nominations/tests/test_management_views.py @@ -1,14 +1,14 @@ import datetime -from django.test import TestCase, Client -from django.urls import reverse 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 ( - UserFactory, - FellowNominationRoundFactory, FellowNominationFactory, + FellowNominationRoundFactory, + UserFactory, ) @@ -127,26 +127,14 @@ def test_wg_member_can_create_round(self): } response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) - self.assertTrue( - FellowNominationRound.objects.filter(year=2026, quarter=2).exists() - ) + 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) - ) + 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") @@ -191,9 +179,7 @@ def test_duplicate_quarter_prevented(self): # 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 - ) + self.assertEqual(FellowNominationRound.objects.filter(year=2026, quarter=3).count(), 1) class FellowNominationRoundUpdateViewTests(TestCase): @@ -232,12 +218,8 @@ def test_wg_member_can_update_round(self): 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) - ) + 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") diff --git a/nominations/tests/test_models.py b/nominations/tests/test_models.py index a016eb462..e03ec0ef6 100644 --- a/nominations/tests/test_models.py +++ b/nominations/tests/test_models.py @@ -1,19 +1,19 @@ 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, - FellowNominationRound, FellowNomination, FellowNominationVote, ) from nominations.tests.factories import ( - UserFactory, - FellowNominationRoundFactory, FellowNominationFactory, + FellowNominationRoundFactory, + UserFactory, ) @@ -37,64 +37,51 @@ def test_slug_auto_generated(self): @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) - ) + 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) - ) + 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) - ) + 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) - ) + 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) - ) + 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) - ) + 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) - ) + 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(Exception): + with self.assertRaises(IntegrityError): FellowNominationRoundFactory(year=2026, quarter=1) class FellowNominationTests(TestCase): def setUp(self): self.round = FellowNominationRoundFactory( - year=2026, quarter=1, + 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), @@ -102,7 +89,8 @@ def setUp(self): review_end=datetime.date(2026, 3, 20), ) self.expiry_round = FellowNominationRoundFactory( - year=2026, quarter=4, + 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), @@ -126,9 +114,7 @@ def test_get_absolute_url(self): @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) - ) + 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): @@ -143,9 +129,7 @@ def test_is_active_false_when_not_accepted(self): @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) - ) + 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): @@ -239,7 +223,7 @@ def test_unique_together_prevents_duplicate_vote(self): voter=voter, vote="yes", ) - with self.assertRaises(Exception): + with self.assertRaises(IntegrityError): FellowNominationVote.objects.create( nomination=self.nomination, voter=voter, @@ -250,7 +234,8 @@ def test_unique_together_prevents_duplicate_vote(self): class FellowNominationQuerySetTests(TestCase): def setUp(self): self.round = FellowNominationRoundFactory( - year=2026, quarter=1, + 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), @@ -258,7 +243,8 @@ def setUp(self): review_end=datetime.date(2026, 3, 20), ) self.future_round = FellowNominationRoundFactory( - year=2026, quarter=4, + 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), @@ -268,10 +254,8 @@ def setUp(self): @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) - ) - nom = FellowNominationFactory( + 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", @@ -280,10 +264,8 @@ def test_active_excludes_accepted(self, mock_now): @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) - ) - nom = FellowNominationFactory( + 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", @@ -291,19 +273,20 @@ def test_active_includes_pending(self, mock_now): self.assertEqual(FellowNomination.objects.active().count(), 1) def test_for_round(self): - nom1 = FellowNominationFactory( + FellowNominationFactory( nomination_round=self.round, expiry_round=self.future_round, ) round2 = FellowNominationRoundFactory( - year=2026, quarter=2, + 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), ) - nom2 = FellowNominationFactory( + FellowNominationFactory( nomination_round=round2, expiry_round=self.future_round, ) diff --git a/nominations/tests/test_review_views.py b/nominations/tests/test_review_views.py index e36d07336..8c2332229 100644 --- a/nominations/tests/test_review_views.py +++ b/nominations/tests/test_review_views.py @@ -1,16 +1,16 @@ import datetime from unittest.mock import patch -from django.test import TestCase, Client +from django.contrib.auth.models import Group +from django.test import Client, TestCase from django.urls import reverse from django.utils import timezone -from django.contrib.auth.models import Group from nominations.models import FellowNomination, FellowNominationVote from nominations.tests.factories import ( - UserFactory, - FellowNominationRoundFactory, FellowNominationFactory, + FellowNominationRoundFactory, + UserFactory, ) @@ -56,9 +56,7 @@ def test_login_required(self): @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) - ) + 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, @@ -88,9 +86,7 @@ def test_active_view_default(self, mock_now): @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) - ) + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 2, 1, 12, 0)) expiry_round = FellowNominationRoundFactory( year=2026, quarter=4, @@ -118,9 +114,7 @@ def test_all_view(self, mock_now): @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) - ) + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 2, 1, 12, 0)) round_q2 = FellowNominationRoundFactory( year=2026, quarter=2, @@ -183,39 +177,27 @@ def setUp(self): 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} - ) + 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} - ) + response = self.client.post(self.url, {"status": FellowNomination.UNDER_REVIEW}) self.assertEqual(response.status_code, 403) - @patch( - "nominations.views.FellowNominationAcceptedNotification.notify" - ) + @patch("nominations.views.FellowNominationAcceptedNotification.notify") def test_notification_sent_on_accept(self, mock_notify): - response = self.client.post( - self.url, {"status": FellowNomination.ACCEPTED} - ) + 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" - ) + @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} - ) + 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) diff --git a/nominations/tests/test_roster_views.py b/nominations/tests/test_roster_views.py index fddaf3a67..18fe988a7 100644 --- a/nominations/tests/test_roster_views.py +++ b/nominations/tests/test_roster_views.py @@ -1,4 +1,4 @@ -from django.test import TestCase, Client +from django.test import Client, TestCase from django.urls import reverse from nominations.models import Fellow @@ -44,9 +44,7 @@ def test_alphabetical_ordering(self): 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" - ) + 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) @@ -64,9 +62,7 @@ def test_year_displayed(self): def test_emeritus_year_displayed(self): """Emeritus fellows should show both elected and emeritus year.""" - Fellow.objects.create( - name="Old Fellow", year_elected=2005, status="emeritus", emeritus_year=2020 - ) + 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 (2005/2020)") diff --git a/nominations/tests/test_views.py b/nominations/tests/test_views.py index 1a2f1d1bb..b85e0b801 100644 --- a/nominations/tests/test_views.py +++ b/nominations/tests/test_views.py @@ -1,16 +1,16 @@ import datetime from unittest.mock import patch -from django.test import TestCase, Client +from django.contrib.auth.models import Group +from django.test import Client, TestCase from django.urls import reverse from django.utils import timezone -from django.contrib.auth.models import Group -from nominations.models import Fellow, FellowNomination, FellowNominationRound +from nominations.models import Fellow, FellowNomination from nominations.tests.factories import ( - UserFactory, - FellowNominationRoundFactory, FellowNominationFactory, + FellowNominationRoundFactory, + UserFactory, ) @@ -20,7 +20,8 @@ def setUp(self): self.user = UserFactory() self.client.login(username=self.user.username, password="testpass123") self.round = FellowNominationRoundFactory( - year=2026, quarter=1, + 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), @@ -50,9 +51,7 @@ def test_404_when_no_open_round(self): @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) - ) + mock_now.return_value = timezone.make_aware(datetime.datetime(2026, 1, 15, 12, 0)) data = { "nominee_name": "Jane Doe", "nominee_email": "jane@example.com", @@ -70,9 +69,7 @@ def test_successful_submission(self, mock_now, mock_wg_notify, mock_nominator_no @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) - ) + 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", @@ -85,7 +82,7 @@ def test_fellow_warning_shown(self, mock_now, mock_wg_notify, mock_nominator_not "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", } - response = self.client.post(self.url, data, follow=True) + 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) From f3efec91d249164aac7d1610d3adbc7aa966f25f Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:47:12 -0600 Subject: [PATCH 24/40] custom roster styling for controls --- templates/nominations/fellows_roster.html | 227 +++++++++++++++++++--- 1 file changed, 205 insertions(+), 22 deletions(-) diff --git a/templates/nominations/fellows_roster.html b/templates/nominations/fellows_roster.html index 6e4dbe4f6..92ae11204 100644 --- a/templates/nominations/fellows_roster.html +++ b/templates/nominations/fellows_roster.html @@ -8,46 +8,229 @@ {% block left_sidebar %}{% endblock %} {% block content_attributes %}{% endblock %} +{% block head %} + +{% endblock %} + {% block content %}

    PSF Fellows

    -

    There are currently {{ total_count }} 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 +
    - {% if active_fellows %} -

    Fellows ({{ active_count }})

    -
      +
    - {% endif %} - {% if emeritus_fellows %} -

    Emeritus Fellows ({{ emeritus_count }})

    -
      {% for fellow in emeritus_fellows %} -
    • {{ fellow.name }} ({{ fellow.year_elected }}{% if fellow.emeritus_year %}/{{ fellow.emeritus_year }}{% endif %})
    • +
    • + {{ fellow.name }} + ({{ fellow.year_elected }}{% if fellow.emeritus_year %}–{{ fellow.emeritus_year }}{% endif %}) + Emeritus +
    • {% endfor %} -
    - {% endif %} - {% if deceased_fellows %} -

    In Memoriam ({{ deceased_count }})

    -
      {% for fellow in deceased_fellows %} -
    • - {{ fellow.name }} ({{ fellow.year_elected }}) +
    • + {{ fellow.name }} + ({{ fellow.year_elected }}) + In Memoriam {% if fellow.notes %}
      {{ fellow.notes }}{% endif %}
    • {% endfor %} -
    - {% endif %} + - {% if not active_fellows and not emeritus_fellows and not deceased_fellows %} -

    No PSF Fellows found.

    +

    No fellows match your filters.

    {% endif %}
    {% endblock content %} + +{% block extra_js %} + +{% endblock extra_js %} From 7d76acea85cc16086a481d6847f33defd6f15608 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:47:24 -0600 Subject: [PATCH 25/40] give ctx for year --- nominations/views.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nominations/views.py b/nominations/views.py index 98cca3530..942b2c628 100644 --- a/nominations/views.py +++ b/nominations/views.py @@ -649,4 +649,9 @@ def get_context_data(self, **kwargs): 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 From d9d5bbd34f282c574f020009c148d40a46abe86e Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:47:30 -0600 Subject: [PATCH 26/40] update test --- nominations/tests/test_roster_views.py | 45 ++++++++++++++++++++++---- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/nominations/tests/test_roster_views.py b/nominations/tests/test_roster_views.py index 18fe988a7..8dd1280ed 100644 --- a/nominations/tests/test_roster_views.py +++ b/nominations/tests/test_roster_views.py @@ -58,13 +58,15 @@ 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, "Year Fellow (2019)") + self.assertContains(response, "(2019)") def test_emeritus_year_displayed(self): - """Emeritus fellows should show both elected and emeritus year.""" + """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 (2005/2020)") + 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.""" @@ -90,12 +92,41 @@ def test_sections_in_context(self): self.assertEqual(response.context["deceased_count"], 1) self.assertEqual(response.context["total_count"], 4) - def test_section_headings_rendered(self): - """Each section heading should appear when fellows of that status exist.""" + 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, "Fellows (1)") - self.assertContains(response, "Emeritus Fellows (1)") + 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") From 8b225b0afc85e002721a564dd0e415551cc3c7d3 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 22:51:53 -0600 Subject: [PATCH 27/40] multi-column so scroll isnt a pita --- templates/nominations/fellows_roster.html | 192 ++++++++++++++++++---- 1 file changed, 164 insertions(+), 28 deletions(-) diff --git a/templates/nominations/fellows_roster.html b/templates/nominations/fellows_roster.html index 92ae11204..546cd46ca 100644 --- a/templates/nominations/fellows_roster.html +++ b/templates/nominations/fellows_roster.html @@ -10,85 +10,221 @@ {% block head %} {% endblock %} @@ -141,7 +277,7 @@

    PSF Fellows

    {{ fellow.name }} ({{ fellow.year_elected }}) In Memoriam - {% if fellow.notes %}
    {{ fellow.notes }}{% endif %} + {% if fellow.notes %}{{ fellow.notes }}{% endif %}
  • {% endfor %} From 7f77680c39e092510bd6e245d6ca35f9f5f8fcaf Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:05:56 -0600 Subject: [PATCH 28/40] redirect instewad --- nominations/tests/test_roster_views.py | 7 ++++--- pydotorg/urls.py | 4 ++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/nominations/tests/test_roster_views.py b/nominations/tests/test_roster_views.py index 8dd1280ed..b7db184a5 100644 --- a/nominations/tests/test_roster_views.py +++ b/nominations/tests/test_roster_views.py @@ -15,10 +15,11 @@ def test_public_access_no_login_required(self): response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - def test_alt_url_works(self): - """The alternate URL /psf/fellows-roster/ should also work.""" + 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, 200) + 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.""" diff --git a/pydotorg/urls.py b/pydotorg/urls.py index 3c151d00f..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 @@ -43,7 +43,7 @@ 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/', FellowsRoster.as_view(), name='fellows-roster-alt'), + 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'), From 2aaf55c6faab28d7b6c5fa521311e7ae0bf8aab0 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:06:05 -0600 Subject: [PATCH 29/40] fix button --- templates/nominations/fellows_roster.html | 56 +++++++++++++++-------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/templates/nominations/fellows_roster.html b/templates/nominations/fellows_roster.html index 546cd46ca..9b1b15e9d 100644 --- a/templates/nominations/fellows_roster.html +++ b/templates/nominations/fellows_roster.html @@ -47,41 +47,59 @@ gap: 3px; } .fellows-status-tabs .tab-btn { - padding: 6px 13px; - border: 1px solid #caccce; - border-radius: 3px; - background: #fff; + display: inline-block !important; + padding: 6px 13px !important; + margin: 0 !important; + border: 1px solid #caccce !important; + border-radius: 3px !important; + background: #fff !important; cursor: pointer; font-size: 13px; - color: #444; + color: #444 !important; transition: background .15s, border-color .15s; line-height: 1.4; + box-shadow: none !important; + text-shadow: none !important; + background-image: none !important; } -.fellows-status-tabs .tab-btn:hover { - background: #e6e8ea; - border-color: #999; +.fellows-status-tabs .tab-btn:hover, +.fellows-status-tabs .tab-btn:focus, +.fellows-status-tabs .tab-btn:active { + background: #e6e8ea !important; + border-color: #999 !important; + color: #444 !important; } -.fellows-status-tabs .tab-btn.active { - background: #3776ab; +.fellows-status-tabs .tab-btn.active, +.fellows-status-tabs .tab-btn.active:hover, +.fellows-status-tabs .tab-btn.active:focus { + background: #3776ab !important; color: #fff !important; - border-color: #3776ab; + border-color: #3776ab !important; } /* ---- Sort toggle ---- */ .fellows-sort-toggle { - padding: 6px 13px; - border: 1px solid #caccce; - border-radius: 3px; - background: #fff; + display: inline-block !important; + padding: 6px 13px !important; + margin: 0 !important; + border: 1px solid #caccce !important; + border-radius: 3px !important; + background: #fff !important; cursor: pointer; font-size: 13px; - color: #444; + color: #444 !important; transition: background .15s; line-height: 1.4; + box-shadow: none !important; + text-shadow: none !important; + background-image: none !important; } -.fellows-sort-toggle:hover { - background: #e6e8ea; - border-color: #999; +.fellows-sort-toggle:hover, +.fellows-sort-toggle:focus, +.fellows-sort-toggle:active { + background: #e6e8ea !important; + border-color: #999 !important; + color: #444 !important; } /* ---- Counter ---- */ From 418022f5ad34430f2879744ed594fdad2bf044fd Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:19:54 -0600 Subject: [PATCH 30/40] why so many lines, 120 chars is life --- nominations/urls.py | 83 ++++++++++++++------------------------------- 1 file changed, 25 insertions(+), 58 deletions(-) diff --git a/nominations/urls.py b/nominations/urls.py index 25985d9f8..de23c2cc5 100644 --- a/nominations/urls.py +++ b/nominations/urls.py @@ -1,67 +1,34 @@ -from nominations 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", - ), + 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(), + 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", - ), + 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"), ] From da26d5faf75a20f930d7e39ceabbc4881bc51109 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:24:29 -0600 Subject: [PATCH 31/40] Update nominations/migrations/0003_fellow_nominations.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- nominations/migrations/0003_fellow_nominations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nominations/migrations/0003_fellow_nominations.py b/nominations/migrations/0003_fellow_nominations.py index b397c7960..b0b4ecaab 100644 --- a/nominations/migrations/0003_fellow_nominations.py +++ b/nominations/migrations/0003_fellow_nominations.py @@ -49,7 +49,7 @@ class Migration(migrations.Migration): ("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)), + ("nomination_statement", markupfield.fields.MarkupField(rendered_field=True, escape_html=True)), ( "nomination_statement_markup_type", models.CharField( From 6f2b20e6e42b9dbb8f413ab3b23b9a421f147ea1 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:29:48 -0600 Subject: [PATCH 32/40] refactor: use IntegerChoices for FellowNominationRound quarters Replaces manual Q1-Q4 constants and QUARTER_CHOICES tuple with a models.IntegerChoices enum for cleaner, more idiomatic Django. Co-Authored-By: Claude Opus 4.6 --- nominations/forms.py | 8 ++++---- nominations/models.py | 17 ++++++----------- ruff.toml | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 15 deletions(-) create mode 100644 ruff.toml diff --git a/nominations/forms.py b/nominations/forms.py index 0e2ba88fb..b36151608 100644 --- a/nominations/forms.py +++ b/nominations/forms.py @@ -136,25 +136,25 @@ class FellowNominationRoundForm(forms.ModelForm): # Quarter start/end date ranges per quarter number QUARTER_DATES = { - FellowNominationRound.Q1: { + FellowNominationRound.Quarter.Q1: { "quarter_start": (1, 1), "quarter_end": (3, 31), "nominations_cutoff": (2, 20), "review_end": (3, 20), }, - FellowNominationRound.Q2: { + FellowNominationRound.Quarter.Q2: { "quarter_start": (4, 1), "quarter_end": (6, 30), "nominations_cutoff": (5, 20), "review_end": (6, 20), }, - FellowNominationRound.Q3: { + FellowNominationRound.Quarter.Q3: { "quarter_start": (7, 1), "quarter_end": (9, 30), "nominations_cutoff": (8, 20), "review_end": (9, 20), }, - FellowNominationRound.Q4: { + FellowNominationRound.Quarter.Q4: { "quarter_start": (10, 1), "quarter_end": (12, 31), "nominations_cutoff": (11, 20), diff --git a/nominations/models.py b/nominations/models.py index ac7ac8516..63e9d5974 100644 --- a/nominations/models.py +++ b/nominations/models.py @@ -318,19 +318,14 @@ def __str__(self): class FellowNominationRound(models.Model): """Quarterly round for PSF Fellow Work Group consideration.""" - Q1 = 1 - Q2 = 2 - Q3 = 3 - Q4 = 4 - QUARTER_CHOICES = ( - (Q1, "Q1 (Jan-Mar)"), - (Q2, "Q2 (Apr-Jun)"), - (Q3, "Q3 (Jul-Sep)"), - (Q4, "Q4 (Oct-Dec)"), - ) + 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 = 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( diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 000000000..a467dc8b3 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,41 @@ +target-version = "py312" +line-length = 120 + +[lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "DJ", # flake8-django +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[lint.isort] +known-first-party = [ + "pydotorg", + "blogs", + "boxes", + "cms", + "codesamples", + "community", + "companies", + "downloads", + "events", + "jobs", + "mailing", + "minutes", + "nominations", + "pages", + "sponsors", + "successstories", + "users", +] + +[format] +quote-style = "double" \ No newline at end of file From 1233d20094b88f3ed967e22c8b625c07605ef1a5 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:30:10 -0600 Subject: [PATCH 33/40] remove unnecessary section comment in settings Co-Authored-By: Claude Opus 4.6 --- pydotorg/settings/base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pydotorg/settings/base.py b/pydotorg/settings/base.py index c73d10927..04d6d4e62 100644 --- a/pydotorg/settings/base.py +++ b/pydotorg/settings/base.py @@ -310,7 +310,6 @@ ) PYPI_SPONSORS_CSV = os.path.join(BASE, "data", "pypi-sponsors.csv") -# Fellow Nominations FELLOW_WG_NOTIFICATION_EMAIL = config( "FELLOW_WG_NOTIFICATION_EMAIL", default="psf-fellow@python.org" ) From 75b1923aa18de77b4e8498232d2dce344133e7bf Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:30:53 -0600 Subject: [PATCH 34/40] replace deprecated
    tags with div style in templates Co-Authored-By: Claude Opus 4.6 --- templates/nominations/fellow_nomination_confirm_delete.html | 4 ++-- templates/nominations/fellow_nomination_manage_form.html | 4 ++-- templates/nominations/fellow_nomination_status_form.html | 4 ++-- templates/nominations/fellow_nomination_vote_form.html | 4 ++-- templates/nominations/fellow_round_form.html | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/templates/nominations/fellow_nomination_confirm_delete.html b/templates/nominations/fellow_nomination_confirm_delete.html index 26425c422..c75e83c15 100644 --- a/templates/nominations/fellow_nomination_confirm_delete.html +++ b/templates/nominations/fellow_nomination_confirm_delete.html @@ -28,9 +28,9 @@

    Delete Fellow Nomination

    {% csrf_token %} -
    +
    -
    +

← Cancel

diff --git a/templates/nominations/fellow_nomination_manage_form.html b/templates/nominations/fellow_nomination_manage_form.html index 73c863f31..5478893d8 100644 --- a/templates/nominations/fellow_nomination_manage_form.html +++ b/templates/nominations/fellow_nomination_manage_form.html @@ -31,9 +31,9 @@

Edit Nomination: {{ object.nominee_name }}

{{ form.as_table }}
-
+
-
+

← Cancel

diff --git a/templates/nominations/fellow_nomination_status_form.html b/templates/nominations/fellow_nomination_status_form.html index 0e7320d14..07c5e693e 100644 --- a/templates/nominations/fellow_nomination_status_form.html +++ b/templates/nominations/fellow_nomination_status_form.html @@ -29,9 +29,9 @@

Update Status: {{ object.nominee_name }}

{{ form.as_table }}
-
+
-
+

← Cancel

diff --git a/templates/nominations/fellow_nomination_vote_form.html b/templates/nominations/fellow_nomination_vote_form.html index a6d41d74f..9a49bb62f 100644 --- a/templates/nominations/fellow_nomination_vote_form.html +++ b/templates/nominations/fellow_nomination_vote_form.html @@ -36,9 +36,9 @@

Statement

{{ form.as_table }}
-
+
-
+

← Cancel

diff --git a/templates/nominations/fellow_round_form.html b/templates/nominations/fellow_round_form.html index bc4ba1bb1..9ea0d34a8 100644 --- a/templates/nominations/fellow_round_form.html +++ b/templates/nominations/fellow_round_form.html @@ -23,11 +23,11 @@

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


-
+
-
+

← Cancel

From 2cf5d48e93ff7ccf44c465cdad055f3d9246afa7 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:32:34 -0600 Subject: [PATCH 35/40] use assertEqual instead of assertTrue for better assertion messages Co-Authored-By: Claude Opus 4.6 --- nominations/tests/test_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nominations/tests/test_views.py b/nominations/tests/test_views.py index b85e0b801..a240ab806 100644 --- a/nominations/tests/test_views.py +++ b/nominations/tests/test_views.py @@ -86,7 +86,7 @@ def test_fellow_warning_shown(self, mock_now, mock_wg_notify, mock_nominator_not self.assertEqual(FellowNomination.objects.count(), 1) nom = FellowNomination.objects.first() self.assertTrue(nom.nominee_is_fellow_at_submission) - self.assertTrue(nom.nominee_user == fellow_user) + self.assertEqual(nom.nominee_user, fellow_user) class FellowNominationDetailViewTests(TestCase): From 83490eba91c2d4490c15030286079676235b0672 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:32:50 -0600 Subject: [PATCH 36/40] make local email backend configurable via env var Co-Authored-By: Claude Opus 4.6 --- pydotorg/settings/local.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pydotorg/settings/local.py b/pydotorg/settings/local.py index 33684f0f3..676bb3665 100644 --- a/pydotorg/settings/local.py +++ b/pydotorg/settings/local.py @@ -32,7 +32,10 @@ }, } -EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_BACKEND = config( + 'EMAIL_BACKEND', + default='django.core.mail.backends.smtp.EmailBackend', +) EMAIL_HOST = config('EMAIL_HOST', default='maildev') EMAIL_PORT = config('EMAIL_PORT', default=1025, cast=int) From ebe5fed2e1394175a67351771f0f1bacb9ecc5cc Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:32:52 -0600 Subject: [PATCH 37/40] add error logging to close_expired celery task Co-Authored-By: Claude Opus 4.6 --- nominations/tasks.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/nominations/tasks.py b/nominations/tasks.py index 1a17edcb4..dc2b3aaf6 100644 --- a/nominations/tasks.py +++ b/nominations/tasks.py @@ -1,8 +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.""" - call_command("close_expired_fellow_nominations") + try: + call_command("close_expired_fellow_nominations") + except Exception: + logger.exception("Failed to close expired Fellow nominations") + raise From a0addcf0c3588a5fdd0f6ce265e64bdd78c7503e Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:37:50 -0600 Subject: [PATCH 38/40] use queryset.update() for bulk status change in close_expired command Single query instead of per-row save loop. Co-Authored-By: Claude Opus 4.6 --- .../management/commands/close_expired_fellow_nominations.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/nominations/management/commands/close_expired_fellow_nominations.py b/nominations/management/commands/close_expired_fellow_nominations.py index d9b036089..030f8f1e6 100644 --- a/nominations/management/commands/close_expired_fellow_nominations.py +++ b/nominations/management/commands/close_expired_fellow_nominations.py @@ -13,9 +13,5 @@ def handle(self, *args, **options): status__in=[FellowNomination.PENDING, FellowNomination.UNDER_REVIEW], expiry_round__quarter_end__lt=today, ) - count = 0 - for nomination in expired: - nomination.status = FellowNomination.NOT_ACCEPTED - nomination.save() - count += 1 + count = expired.update(status=FellowNomination.NOT_ACCEPTED) self.stdout.write(self.style.SUCCESS(f"Closed {count} expired Fellow nomination(s).")) From d30dd991059b91e5be7e71375c79b6a5d434e4b5 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:38:46 -0600 Subject: [PATCH 39/40] revert: keep EMAIL_BACKEND as plain assignment in local settings maildev is always available via docker-compose, no need for env var configurability here. Co-Authored-By: Claude Opus 4.6 --- pydotorg/settings/local.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pydotorg/settings/local.py b/pydotorg/settings/local.py index 676bb3665..33684f0f3 100644 --- a/pydotorg/settings/local.py +++ b/pydotorg/settings/local.py @@ -32,10 +32,7 @@ }, } -EMAIL_BACKEND = config( - 'EMAIL_BACKEND', - default='django.core.mail.backends.smtp.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) From fb500137357ce14032e300d32818c5f1c8e2f130 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Thu, 5 Feb 2026 23:42:33 -0600 Subject: [PATCH 40/40] remove ruff.toml accidentally committed Co-Authored-By: Claude Opus 4.6 --- ruff.toml | 41 ----------------------------------------- 1 file changed, 41 deletions(-) delete mode 100644 ruff.toml diff --git a/ruff.toml b/ruff.toml deleted file mode 100644 index a467dc8b3..000000000 --- a/ruff.toml +++ /dev/null @@ -1,41 +0,0 @@ -target-version = "py312" -line-length = 120 - -[lint] -select = [ - "E", # pycodestyle errors - "F", # pyflakes - "W", # pycodestyle warnings - "I", # isort - "UP", # pyupgrade - "B", # flake8-bugbear - "SIM", # flake8-simplify - "DJ", # flake8-django -] -ignore = [ - "E501", # line too long (handled by formatter) -] - -[lint.isort] -known-first-party = [ - "pydotorg", - "blogs", - "boxes", - "cms", - "codesamples", - "community", - "companies", - "downloads", - "events", - "jobs", - "mailing", - "minutes", - "nominations", - "pages", - "sponsors", - "successstories", - "users", -] - -[format] -quote-style = "double" \ No newline at end of file