Skip to content

Commit 891e5f3

Browse files
committed
Report MIT email for student members
We *require* students to provide a valid `@mit.edu` email address in order to pay dues as students -- this is valuable for identifying somebody's Kerberos even if they choose to have, say, a Gmail address as their preferred address.
1 parent d8978f8 commit 891e5f3

File tree

3 files changed

+122
-2
lines changed

3 files changed

+122
-2
lines changed

ws/api_views.py

+6
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,8 @@ class MemberInfo(TypedDict):
645645
email: str
646646
affiliation: str
647647
num_rentals: int
648+
# Only reported if they are an MIT student!
649+
mit_email: str | None
648650
# Fields from TripInformation, if found
649651
is_leader: NotRequired[bool]
650652
num_trips_attended: NotRequired[int]
@@ -660,6 +662,7 @@ def _flat_members_info(
660662
for info in members:
661663
flat_info: MemberInfo = {
662664
"email": info.email,
665+
"mit_email": info.mit_email,
663666
"affiliation": info.affiliation,
664667
"num_rentals": info.num_rentals,
665668
}
@@ -675,6 +678,9 @@ def _flat_members_info(
675678
"num_discounts": info.trips_information.num_discounts,
676679
}
677680
)
681+
# If there's a verified MIT email address from the trips site, use it!
682+
if info.trips_information.verified_mit_email is not None:
683+
flat_info["mit_email"] = info.trips_information.verified_mit_email
678684
yield flat_info
679685

680686
def get(self, request: HttpRequest, *args: Any, **kwargs: Any) -> JsonResponse:

ws/tests/views/test_api_views.py

+78-1
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,15 @@ def test_fetches_async_by_default(self):
659659
verified=True,
660660
primary=False,
661661
)
662+
# Reflect reality -- to be an MIT student, you *must* have a verified mit.edu
663+
self.participant.affiliation = "MU"
664+
self.participant.save()
665+
factories.EmailAddressFactory.create(
666+
user=self.participant.user,
667+
verified=True,
668+
primary=False,
669+
670+
)
662671
factories.SignUpFactory.create(participant=self.participant, on_trip=True)
663672

664673
with mock.patch.object(tasks.update_member_stats, "delay") as update:
@@ -680,6 +689,7 @@ def test_fetches_async_by_default(self):
680689
"num_trips_attended": 1,
681690
"num_trips_led": 0,
682691
"num_discounts": 0,
692+
"mit_email": "[email protected]",
683693
},
684694
],
685695
},
@@ -712,6 +722,8 @@ def test_no_matching_participant(self):
712722
self._expect_members(
713723
{
714724
"email": "[email protected]",
725+
# We still report the MIT email from the geardb!
726+
"mit_email": "[email protected]",
715727
"affiliation": "MIT undergrad",
716728
"num_rentals": 3,
717729
}
@@ -761,6 +773,7 @@ def test_matches_on_verified_emails_only(self) -> None:
761773
{
762774
# We report their trips email as preferred!
763775
"email": "[email protected]",
776+
"mit_email": None,
764777
"affiliation": "Non-MIT undergrad",
765778
# Found a matching account!
766779
"is_leader": False,
@@ -773,10 +786,61 @@ def test_matches_on_verified_emails_only(self) -> None:
773786
{
774787
"affiliation": "MIT affiliate",
775788
"email": "[email protected]",
789+
"mit_email": None,
776790
"num_rentals": 0,
777791
},
778792
)
779793

794+
@responses.activate
795+
def test_ignores_possibly_old_mit_edu(self):
796+
with freeze_time("2019-02-22 12:25:00 EST"):
797+
cached = models.MembershipStats.load()
798+
cached.response = [
799+
{
800+
"id": 37,
801+
"affiliation": "MIT alum (former student)",
802+
"alternate_emails": ["[email protected]"],
803+
"email": "[email protected]",
804+
"num_rentals": 3,
805+
}
806+
]
807+
cached.save()
808+
809+
# Simulate an old MIT email they may no longer own!
810+
factories.EmailAddressFactory.create(
811+
user=self.participant.user,
812+
813+
verified=True,
814+
primary=False,
815+
)
816+
# MIT alum -- they *used* to own [email protected]
817+
self.participant.affiliation = "ML"
818+
self.participant.save()
819+
820+
with mock.patch.object(tasks.update_member_stats, "delay"):
821+
response = self.client.get("/stats/membership.json") # No cache_strategy
822+
823+
self.assertEqual(
824+
response.json(),
825+
{
826+
# We used the cached information from the geardb
827+
"retrieved_at": "2019-02-22T12:25:00-05:00",
828+
"members": [
829+
{
830+
"email": self.participant.email,
831+
"affiliation": "MIT alum (former student)",
832+
"num_rentals": 3,
833+
"is_leader": True,
834+
"num_trips_attended": 0,
835+
"num_trips_led": 0,
836+
"num_discounts": 0,
837+
# We do *not* report the mit.edu email -- it may be old
838+
"mit_email": None,
839+
},
840+
],
841+
},
842+
)
843+
780844
@responses.activate
781845
def test_trips_data_included(self):
782846
responses.get(
@@ -812,6 +876,15 @@ def test_trips_data_included(self):
812876
verified=True,
813877
primary=False,
814878
)
879+
# Reflect reality -- to be an MIT student, you *must* have a verified mit.edu
880+
self.participant.affiliation = "MU"
881+
self.participant.save()
882+
factories.EmailAddressFactory.create(
883+
user=self.participant.user,
884+
verified=True,
885+
primary=False,
886+
887+
)
815888
bob = factories.ParticipantFactory.create(email="[email protected]")
816889
factories.EmailAddressFactory.create(
817890
user=bob.user, email="[email protected]", verified=True, primary=False
@@ -843,6 +916,7 @@ def test_trips_data_included(self):
843916
self._expect_members(
844917
{
845918
"email": "[email protected]", # Preferred email!
919+
"mit_email": None,
846920
"affiliation": "Non-MIT undergrad",
847921
"num_rentals": 0,
848922
"is_leader": False, # Not presently a leader!
@@ -852,6 +926,7 @@ def test_trips_data_included(self):
852926
},
853927
{
854928
"email": self.participant.email,
929+
"mit_email": "[email protected]",
855930
"affiliation": "MIT undergrad",
856931
"num_rentals": 3,
857932
"is_leader": True,
@@ -863,6 +938,7 @@ def test_trips_data_included(self):
863938
{
864939
"affiliation": "MIT affiliate",
865940
"email": "[email protected]",
941+
"mit_email": None,
866942
"num_rentals": 0,
867943
},
868944
)
@@ -878,5 +954,6 @@ def test_trips_data_included(self):
878954
# 1. Count trips per participant (separate to avoid double-counting)
879955
# 2. Count discounts, trips led, per participant
880956
# 3. Get all emails (lowercased, for mapping back to participant records)
881-
with self.assertNumQueries(3):
957+
# 4. Get MIT email addresses
958+
with self.assertNumQueries(4):
882959
stats.with_trips_information()

ws/utils/member_stats.py

+38-1
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88
from allauth.account.models import EmailAddress
99
from django.db.models import Count, Exists, OuterRef
1010
from django.db.models.functions import Lower
11+
from mitoc_const import affiliations
1112
from typing_extensions import assert_never
1213

1314
from ws import models, tasks
1415

1516
if TYPE_CHECKING:
16-
from collections.abc import Collection
17+
from collections.abc import Collection, Iterator
1718

1819

1920
logger = logging.getLogger(__name__)
@@ -32,10 +33,15 @@ class TripsInformation(NamedTuple):
3233
# Email address as given on Participant object
3334
# (We assume this is their preferred current email)
3435
email: str
36+
# If they are a student & have verified an mit.edu email, we report it
37+
verified_mit_email: str | None
3538

3639

3740
class MembershipInformation(NamedTuple):
3841
email: str
42+
# In order to claim MIT student status, you must verify you own an @mit.edu email.
43+
# We'll only report this for *current* students (since many others will lack).
44+
mit_email: str | None
3945
alternate_emails: list[str]
4046
person_id: int
4147
affiliation: str
@@ -133,9 +139,26 @@ def fetch_geardb_stats_for_all_members(
133139
else:
134140
assert_never(cache_strategy)
135141

142+
def _mit_emails(member: dict[str, Any]) -> Iterator[str]:
143+
for email in {member["email"], *member["alternate_emails"]}:
144+
if email.lower().endswith("@mit.edu"): # Exclude alum.mit.edu!
145+
yield email.lower()
146+
136147
info = [
137148
MembershipInformation(
138149
email=member["email"],
150+
# If they are a current MIT student, assume they own the first mit.edu.
151+
# To pay dues on the trips site, we *require* they own an MIT email.
152+
# However, it's possible a desk worker manually added the membership.
153+
mit_email=(
154+
next(_mit_emails(member), None)
155+
if member["affiliation"]
156+
in {
157+
affiliations.MIT_UNDERGRAD.VALUE,
158+
affiliations.MIT_GRAD_STUDENT.VALUE,
159+
}
160+
else None
161+
),
139162
alternate_emails=member["alternate_emails"],
140163
person_id=int(member["id"]),
141164
affiliation=member["affiliation"],
@@ -161,6 +184,19 @@ def _get_trip_stats_by_user() -> dict[int, TripsInformation]:
161184
.values_list("participant_id", "num_trips")
162185
)
163186

187+
# Technically, yes, one could have multiple verified mit.edu addresses.
188+
# But that's going to be exceptionally rare, and the dict will handle dupes.
189+
mit_email_for_students: dict[int, str] = dict(
190+
models.Participant.objects.filter(
191+
affiliation__in=[
192+
affiliations.MIT_UNDERGRAD.CODE,
193+
affiliations.MIT_GRAD_STUDENT.CODE,
194+
],
195+
user__emailaddress__verified=True,
196+
user__emailaddress__email__iendswith="@mit.edu",
197+
).values_list("pk", "user__emailaddress__email")
198+
)
199+
164200
additional_stats = (
165201
models.Participant.objects.all()
166202
.annotate(
@@ -187,6 +223,7 @@ def _get_trip_stats_by_user() -> dict[int, TripsInformation]:
187223
return {
188224
par["user_id"]: TripsInformation(
189225
email=par["email"],
226+
verified_mit_email=mit_email_for_students.get(par["pk"]),
190227
is_leader=par["is_leader"],
191228
num_trips_attended=trips_per_participant.get(par["pk"], 0),
192229
num_trips_led=par["num_trips_led"],

0 commit comments

Comments
 (0)