Skip to content

Commit ed42485

Browse files
Merge pull request #694 from djangonaut-space/develop
Production Release - 2026-02-18
2 parents 085d4e3 + 28bee1f commit ed42485

File tree

10 files changed

+450
-60
lines changed

10 files changed

+450
-60
lines changed

accounts/admin.py

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,70 @@
99
from django.contrib.auth.models import Group
1010
from django.contrib import admin as django_admin
1111
from django.core.management import call_command
12+
from django.db.models import Exists, OuterRef, QuerySet
1213
from django.http import HttpResponse, HttpResponseRedirect
1314
from django.urls import reverse
1415

15-
from accounts.models import CustomUser
16-
from accounts.models import Link
17-
from accounts.models import UserAvailability
18-
from accounts.models import UserProfile
16+
from accounts.models import CustomUser, Link, UserAvailability, UserProfile
17+
from home.models.session import SessionMembership
1918
from indymeet.admin import DescriptiveSearchMixin
2019

2120

21+
class PastSessionMembershipFilter(admin.SimpleListFilter):
22+
"""Base filter for users with a membership in a past session.
23+
24+
Subclasses set ``title``, ``parameter_name``, and optionally override
25+
``membership_queryset``. Set ``user_field`` to the FK path from the
26+
filtered model to the user (empty string when the model *is* the user).
27+
"""
28+
29+
title: str
30+
parameter_name: str
31+
user_field: str = ""
32+
membership_queryset = SessionMembership.objects.all()
33+
34+
def lookups(
35+
self, request: admin.ModelAdmin, model_admin: admin.ModelAdmin
36+
) -> list[tuple[str, str]]:
37+
return [
38+
("yes", "Yes"),
39+
("no", "No"),
40+
]
41+
42+
def queryset(self, request: admin.ModelAdmin, queryset: QuerySet) -> QuerySet:
43+
if self.value() not in ("yes", "no"):
44+
return queryset
45+
46+
outer_ref = self.user_field or "pk"
47+
annotated = queryset.annotate(
48+
**{
49+
self.parameter_name: Exists(
50+
self.membership_queryset.filter(user=OuterRef(outer_ref))
51+
)
52+
}
53+
)
54+
return annotated.filter(**{self.parameter_name: self.value() == "yes"})
55+
56+
57+
class PastDjangonautFilter(PastSessionMembershipFilter):
58+
title = "past djangonaut"
59+
parameter_name = "past_djangonaut"
60+
membership_queryset = SessionMembership.objects.djangonauts()
61+
62+
63+
class PastSessionMemberFilter(PastSessionMembershipFilter):
64+
title = "past session member"
65+
parameter_name = "past_session_member"
66+
67+
68+
class RelatedUserPastDjangonautFilter(PastDjangonautFilter):
69+
user_field = "user"
70+
71+
72+
class RelatedUserPastSessionMemberFilter(PastSessionMemberFilter):
73+
user_field = "user"
74+
75+
2276
class ExportCsvMixin:
2377
@admin.action(description="Export Selected")
2478
def export_as_csv(self, request, queryset):
@@ -73,6 +127,7 @@ class CustomUserAdmin(ExportCsvMixin, DescriptiveSearchMixin, BaseUserAdmin):
73127
"profile__github_username",
74128
"date_joined",
75129
)
130+
list_filter = (PastDjangonautFilter, PastSessionMemberFilter)
76131

77132
@admin.action(description="Compare availability of selected users")
78133
def compare_availability_action(
@@ -97,6 +152,7 @@ class UserProfileAdmin(ExportCsvMixin, DescriptiveSearchMixin, admin.ModelAdmin)
97152
inlines = (LinksInline,)
98153
model = UserProfile
99154
actions = ["export_as_csv"]
155+
list_filter = (RelatedUserPastDjangonautFilter, RelatedUserPastSessionMemberFilter)
100156

101157

102158
@admin.register(UserAvailability)
@@ -110,6 +166,11 @@ class UserAvailabilityAdmin(DescriptiveSearchMixin, admin.ModelAdmin):
110166
"user__first_name",
111167
"user__last_name",
112168
)
169+
list_filter = (
170+
RelatedUserPastDjangonautFilter,
171+
RelatedUserPastSessionMemberFilter,
172+
"updated_at",
173+
)
113174
readonly_fields = ("updated_at",)
114175
raw_id_fields = ("user",)
115176

accounts/tests/test_admin.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
from datetime import timedelta
2+
from http import HTTPStatus
3+
4+
from django.test import TestCase
5+
from django.urls import reverse
6+
from django.utils import timezone
7+
8+
from accounts.factories import UserAvailabilityFactory, UserFactory
9+
from accounts.models import CustomUser
10+
from home.factories import SessionFactory, SessionMembershipFactory
11+
from home.models import SessionMembership
12+
13+
14+
class AdminFilterTests(TestCase):
15+
16+
@classmethod
17+
def setUpTestData(cls) -> None:
18+
cls.superuser = CustomUser.objects.create_superuser(
19+
username="admin", email="admin@example.com", password="test"
20+
)
21+
today = timezone.now().date()
22+
past_session = SessionFactory.create(
23+
start_date=today - timedelta(days=90),
24+
end_date=today - timedelta(days=30),
25+
)
26+
future_session = SessionFactory.create(
27+
start_date=today + timedelta(days=30),
28+
end_date=today + timedelta(days=90),
29+
)
30+
31+
cls.past_djangonaut = UserFactory.create(username="past_djangonaut")
32+
SessionMembershipFactory.create(
33+
user=cls.past_djangonaut,
34+
session=past_session,
35+
role=SessionMembership.DJANGONAUT,
36+
team=None,
37+
)
38+
39+
cls.past_navigator = UserFactory.create(username="past_navigator")
40+
SessionMembershipFactory.create(
41+
user=cls.past_navigator,
42+
session=past_session,
43+
role=SessionMembership.NAVIGATOR,
44+
team=None,
45+
)
46+
47+
cls.future_djangonaut = UserFactory.create(username="future_djangonaut")
48+
SessionMembershipFactory.create(
49+
user=cls.future_djangonaut,
50+
session=future_session,
51+
role=SessionMembership.DJANGONAUT,
52+
team=None,
53+
)
54+
55+
cls.no_session_user = UserFactory.create(username="no_session")
56+
57+
UserAvailabilityFactory.create(user=cls.past_djangonaut)
58+
UserAvailabilityFactory.create(user=cls.no_session_user)
59+
60+
def setUp(self) -> None:
61+
self.client.force_login(self.superuser)
62+
63+
def _get_filtered_values(
64+
self, url: str, params: dict, field: str = "username"
65+
) -> set:
66+
response = self.client.get(url, params)
67+
assert response.status_code == HTTPStatus.OK
68+
return set(response.context["cl"].queryset.values_list(field, flat=True))
69+
70+
def test_customuser_past_djangonaut_yes(self) -> None:
71+
url = reverse("admin:accounts_customuser_changelist")
72+
users = self._get_filtered_values(url, {"past_djangonaut": "yes"})
73+
assert "past_djangonaut" in users
74+
assert "future_djangonaut" in users
75+
assert "past_navigator" not in users
76+
77+
def test_customuser_past_djangonaut_no(self) -> None:
78+
url = reverse("admin:accounts_customuser_changelist")
79+
users = self._get_filtered_values(url, {"past_djangonaut": "no"})
80+
assert "past_djangonaut" not in users
81+
assert "past_navigator" in users
82+
assert "no_session" in users
83+
84+
def test_customuser_past_session_member_yes(self) -> None:
85+
url = reverse("admin:accounts_customuser_changelist")
86+
users = self._get_filtered_values(url, {"past_session_member": "yes"})
87+
assert "past_djangonaut" in users
88+
assert "past_navigator" in users
89+
assert "future_djangonaut" in users
90+
assert "no_session" not in users
91+
92+
def test_customuser_past_session_member_no(self) -> None:
93+
url = reverse("admin:accounts_customuser_changelist")
94+
users = self._get_filtered_values(url, {"past_session_member": "no"})
95+
assert "past_djangonaut" not in users
96+
assert "past_navigator" not in users
97+
assert "no_session" in users
98+
99+
def test_userprofile_past_djangonaut_yes(self) -> None:
100+
url = reverse("admin:accounts_userprofile_changelist")
101+
user_ids = self._get_filtered_values(
102+
url, {"past_djangonaut": "yes"}, field="user_id"
103+
)
104+
assert self.past_djangonaut.pk in user_ids
105+
assert self.past_navigator.pk not in user_ids
106+
107+
def test_userprofile_past_session_member_yes(self) -> None:
108+
url = reverse("admin:accounts_userprofile_changelist")
109+
user_ids = self._get_filtered_values(
110+
url, {"past_session_member": "yes"}, field="user_id"
111+
)
112+
assert self.past_djangonaut.pk in user_ids
113+
assert self.past_navigator.pk in user_ids
114+
assert self.future_djangonaut.pk in user_ids
115+
assert self.no_session_user.pk not in user_ids
116+
117+
def test_useravailability_past_djangonaut_yes(self) -> None:
118+
url = reverse("admin:accounts_useravailability_changelist")
119+
user_ids = self._get_filtered_values(
120+
url, {"past_djangonaut": "yes"}, field="user_id"
121+
)
122+
assert self.past_djangonaut.pk in user_ids
123+
assert self.no_session_user.pk not in user_ids
124+
125+
def test_useravailability_past_session_member_no(self) -> None:
126+
url = reverse("admin:accounts_useravailability_changelist")
127+
user_ids = self._get_filtered_values(
128+
url, {"past_session_member": "no"}, field="user_id"
129+
)
130+
assert self.past_djangonaut.pk not in user_ids
131+
assert self.no_session_user.pk in user_ids
132+
133+
def test_useravailability_updated_at_filter(self) -> None:
134+
url = reverse("admin:accounts_useravailability_changelist")
135+
response = self.client.get(url, {"updated_at__gte": "2020-01-01"})
136+
assert response.status_code == HTTPStatus.OK

home/dataclasses.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from dataclasses import dataclass
99
from typing import Optional
1010

11+
from django.urls import reverse
12+
1113
from accounts.models import CustomUser
1214
from home.models import Project, Team, UserSurveyResponse
1315

@@ -52,3 +54,13 @@ class TeamStatistics:
5254
navigator_meeting_hours: int
5355
captain_meetings: list[dict] # Keep as dict since it comes from utils
5456
is_valid: bool
57+
58+
@property
59+
def compare_availability_url(self) -> str:
60+
all_members = self.navigators + ([self.captain] if self.captain else [])
61+
all_members.extend([d.user for d in self.djangonaut_details])
62+
user_ids = [str(u.id) for u in all_members]
63+
return (
64+
reverse("compare_availability")
65+
+ f"?users={','.join(user_ids)}&session={self.team.session_id}"
66+
)

home/factories.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
UserQuestionResponse,
2121
UserSurveyResponse,
2222
Waitlist,
23+
ProjectPreference,
2324
)
2425

2526

@@ -177,3 +178,12 @@ class Meta:
177178
author = factory.SubFactory(UserFactory)
178179
session = factory.SubFactory(SessionFactory)
179180
is_published = False
181+
182+
183+
class ProjectPreferenceFactory(factory.django.DjangoModelFactory):
184+
class Meta:
185+
model = ProjectPreference
186+
187+
user = factory.SubFactory(UserFactory)
188+
session = factory.SubFactory(SessionFactory)
189+
project = factory.SubFactory(ProjectFactory)

home/filters.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ class ApplicantFilterSet(django_filters.FilterSet):
3333
field_name="selection_rank", lookup_expr="lte", label="Maximum Selection Rank"
3434
)
3535

36+
project_preferences = django_filters.ModelChoiceFilter(
37+
field_name="user__project_preferences__project",
38+
queryset=Project.objects.none(),
39+
label="Project Preference",
40+
)
41+
3642
team = django_filters.ModelChoiceFilter(
3743
field_name="user__session_memberships__team",
3844
queryset=Team.objects.none(),
@@ -90,6 +96,9 @@ def __init__(self, *args, session=None, **kwargs):
9096
self.filters["team"].queryset = team_queryset
9197
self.filters["overlap_with_navigators"].queryset = team_queryset
9298
self.filters["overlap_with_captain"].queryset = team_queryset
99+
self.filters["project_preferences"].queryset = (
100+
session.available_projects.all().order_by("name")
101+
)
93102

94103
def filter_by_team(
95104
self, queryset: QuerySet, name: str, value: Team | None

0 commit comments

Comments
 (0)